[automerger skipped] Merge history of ConnectivityT am: 700ffc994f -s ours am: def659a25a -s ours

am skip reason: Merged-In I67c703e3f7aa9d5787f032a79ed62e45412baf4f with SHA-1 41f94ec8a1 is already in history

Original change: https://android-review.googlesource.com/c/platform/packages/modules/Connectivity/+/2042587

Change-Id: I78d7150a1eaa7b92836407d2a33ec505c45c6cc7
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/Tethering/AndroidManifest.xml b/Tethering/AndroidManifest.xml
index 6deb345..b832e16 100644
--- a/Tethering/AndroidManifest.xml
+++ b/Tethering/AndroidManifest.xml
@@ -41,6 +41,7 @@
     <uses-permission android:name="android.permission.UPDATE_APP_OPS_STATS" />
     <uses-permission android:name="android.permission.UPDATE_DEVICE_STATS" />
     <uses-permission android:name="android.permission.WRITE_SETTINGS" />
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
 
     <protected-broadcast android:name="com.android.server.connectivity.tethering.DISABLE_TETHERING" />
 
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 4c4368f..b709693 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -22,16 +22,16 @@
 // different value depending on the branch.
 java_defaults {
     name: "ConnectivityNextEnableDefaults",
-    enabled: false,
+    enabled: true,
 }
 apex_defaults {
     name: "ConnectivityApexDefaults",
     // Tethering app to include in the AOSP apex. Branches that disable the "next" targets may use
     // a stable tethering app instead, but will generally override the AOSP apex to use updatable
     // package names and keys, so that apex will be unused anyway.
-    apps: ["Tethering"], // Replace to "Tethering" if ConnectivityNextEnableDefaults is false.
+    apps: ["TetheringNext"], // Replace to "Tethering" if ConnectivityNextEnableDefaults is false.
 }
-enable_tethering_next_apex = false
+enable_tethering_next_apex = true
 // This is a placeholder comment to avoid merge conflicts
 // as the above target may have different "enabled" values
 // depending on the branch
@@ -60,8 +60,7 @@
         both: {
             jni_libs: [
                 "libframework-connectivity-jni",
-                // Changed in sc-mainline-prod only: no framework-connectivity-t
-                // "libframework-connectivity-tiramisu-jni"
+                "libframework-connectivity-tiramisu-jni"
             ],
         },
     },
@@ -78,6 +77,7 @@
     ],
     apps: [
         "ServiceConnectivityResources",
+        "HalfSheetUX",
     ],
     prebuilts: [
         "current_sdkinfo",
@@ -108,7 +108,7 @@
     name: "com.android.tethering-bootclasspath-fragment",
     contents: [
         "framework-connectivity",
-        // Changed in sc-mainline-prod only: no framework-connectivity-t
+        "framework-connectivity-t",
         "framework-tethering",
     ],
     apex_available: ["com.android.tethering"],
@@ -131,18 +131,15 @@
     // modified by the Soong or platform compat team.
     hidden_api: {
         max_target_r_low_priority: [
-            // Changed in sc-mainline-prod only: no list for
-            // framework-connectivity-t APIs as it is not in the APEX
+            "hiddenapi/hiddenapi-max-target-r-loprio.txt",
 	],
         max_target_o_low_priority: [
             "hiddenapi/hiddenapi-max-target-o-low-priority.txt",
-            // Changed in sc-mainline-prod only: no list for
-            // framework-connectivity-t APIs as it is not in the APEX
+            "hiddenapi/hiddenapi-max-target-o-low-priority-tiramisu.txt",
 	],
         unsupported: [
             "hiddenapi/hiddenapi-unsupported.txt",
-            // Changed in sc-mainline-prod only: no framework-connectivity-t
-            // "hiddenapi/hiddenapi-unsupported-tiramisu.txt",
+            "hiddenapi/hiddenapi-unsupported-tiramisu.txt",
         ],
     },
 }
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index 25489ff..9ca3f14 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -21,7 +21,6 @@
     name: "framework-tethering",
     defaults: ["framework-module-defaults"],
     impl_library_visibility: [
-        "//frameworks/base/packages/Tethering:__subpackages__",
         "//packages/modules/Connectivity/Tethering:__subpackages__",
 
         // Using for test only
diff --git a/Tethering/tests/unit/Android.bp b/Tethering/tests/unit/Android.bp
index d1b8380..37b1bc8 100644
--- a/Tethering/tests/unit/Android.bp
+++ b/Tethering/tests/unit/Android.bp
@@ -67,6 +67,7 @@
         "ext",
         "framework-minus-apex",
         "framework-res",
+        "framework-bluetooth.stubs.module_lib",
         "framework-connectivity.impl",
         "framework-connectivity-t.impl",
         "framework-tethering.impl",
diff --git a/buildstubs-t/Android.bp b/buildstubs-t/Android.bp
deleted file mode 100644
index 9ca3fd2..0000000
--- a/buildstubs-t/Android.bp
+++ /dev/null
@@ -1,80 +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 {
-    // See: http://go/android-license-faq
-    default_applicable_licenses: ["Android-Apache-2.0"],
-}
-
-// Placeholder empty filegroups to avoid merge conflicts on build rules
-// on a branch that does not have the filegroups
-
-filegroup {
-    name: "framework-connectivity-tiramisu-updatable-sources",
-    srcs: [],
-}
-
-filegroup {
-    name: "services.connectivity-tiramisu-updatable-sources",
-    srcs: ["stubs-src/**/*.java"],
-}
-
-filegroup {
-    name: "framework-connectivity-api-shared-srcs",
-    srcs: [],
-}
-
-filegroup {
-    name: "ethernet-service-updatable-sources",
-    srcs: [],
-}
-
-filegroup {
-    name: "services.connectivity-netstats-jni-sources",
-    srcs: [
-        "stubs-src-jni/mock_com_android_server_net_NetworkStatsFactory.cpp",
-        "stubs-src-jni/mock_com_android_server_net_NetworkStatsService.cpp",
-    ],
-    visibility: [
-        "//packages/modules/Connectivity:__subpackages__",
-    ],
-}
-
-filegroup {
-    name: "framework-connectivity-tiramisu-jni-sources",
-    srcs: [
-        "stubs-src-jni/mock_android_net_TrafficStats.cpp",
-    ],
-    visibility: [
-        "//packages/modules/Connectivity:__subpackages__",
-    ],
-}
-
-// Empty replacement for framework-connectivity-t.impl and stubs,
-// as framework-connectivity is disabled in the branch
-java_library {
-    name: "framework-connectivity-t.impl",
-    min_sdk_version: "Tiramisu",
-    sdk_version: "module_current",
-    srcs: [],
-}
-
-java_library {
-    name: "framework-connectivity-t.stubs.module_lib",
-    min_sdk_version: "Tiramisu",
-    sdk_version: "module_current",
-    srcs: [],
-}
diff --git a/buildstubs-t/stubs-src-jni/mock_android_net_TrafficStats.cpp b/buildstubs-t/stubs-src-jni/mock_android_net_TrafficStats.cpp
deleted file mode 100644
index ef5d874..0000000
--- a/buildstubs-t/stubs-src-jni/mock_android_net_TrafficStats.cpp
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-#include <nativehelper/JNIHelp.h>
-
-namespace android {
-
-int register_android_net_TrafficStats(JNIEnv* env) {
-    return JNI_ERR;
-}
-
-};  // namespace android
diff --git a/buildstubs-t/stubs-src-jni/mock_com_android_server_net_NetworkStatsFactory.cpp b/buildstubs-t/stubs-src-jni/mock_com_android_server_net_NetworkStatsFactory.cpp
deleted file mode 100644
index 594a174..0000000
--- a/buildstubs-t/stubs-src-jni/mock_com_android_server_net_NetworkStatsFactory.cpp
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-#include <nativehelper/JNIHelp.h>
-
-namespace android {
-
-int register_android_server_net_NetworkStatsFactory(JNIEnv* env) {
-    return JNI_ERR;
-}
-
-};  // namespace android
diff --git a/buildstubs-t/stubs-src-jni/mock_com_android_server_net_NetworkStatsService.cpp b/buildstubs-t/stubs-src-jni/mock_com_android_server_net_NetworkStatsService.cpp
deleted file mode 100644
index b0c42b0..0000000
--- a/buildstubs-t/stubs-src-jni/mock_com_android_server_net_NetworkStatsService.cpp
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-#include <nativehelper/JNIHelp.h>
-
-namespace android {
-
-int register_android_server_net_NetworkStatsService(JNIEnv* env) {
-    return JNI_ERR;
-}
-
-};  // namespace android
diff --git a/buildstubs-t/stubs-src/android/net/TrafficStats.java b/buildstubs-t/stubs-src/android/net/TrafficStats.java
deleted file mode 100644
index 0b208ac..0000000
--- a/buildstubs-t/stubs-src/android/net/TrafficStats.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package android.net;
-
-import android.content.Context;
-
-/**
- * Fake TrafficStats class for sc-mainline-prod,
- * to allow building the T service-connectivity before sources
- * are moved to the branch.
- */
-public final class TrafficStats {
-    /** Init */
-    public static void init(Context context) {
-        throw new RuntimeException("This is a stub class");
-    }
-}
diff --git a/buildstubs-t/stubs-src/com/android/server/EthernetService.java b/buildstubs-t/stubs-src/com/android/server/EthernetService.java
deleted file mode 100644
index 4a06e1e..0000000
--- a/buildstubs-t/stubs-src/com/android/server/EthernetService.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.ethernet;
-
-import android.content.Context;
-
-/**
- * Fake EthernetService class for branches that do not have the updatable EthernetService yet,
- * to allow building the T service-connectivity before sources are moved to the branch.
- */
-public final class EthernetService {
-    /** Create instance */
-    public static EthernetServiceImpl create(Context ctx) {
-        throw new RuntimeException("This is a stub class");
-    }
-}
-
diff --git a/buildstubs-t/stubs-src/com/android/server/EthernetServiceImpl.java b/buildstubs-t/stubs-src/com/android/server/EthernetServiceImpl.java
deleted file mode 100644
index eb3bfa0..0000000
--- a/buildstubs-t/stubs-src/com/android/server/EthernetServiceImpl.java
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.ethernet;
-
-import android.os.Binder;
-
-/** Stub class for EthernetServiceImpl */
-public class EthernetServiceImpl extends Binder {
-    /** Start service */
-    public void start() {
-        throw new RuntimeException("This is a stub class");
-    }
-}
-
diff --git a/buildstubs-t/stubs-src/com/android/server/IpSecService.java b/buildstubs-t/stubs-src/com/android/server/IpSecService.java
deleted file mode 100644
index bb48c14..0000000
--- a/buildstubs-t/stubs-src/com/android/server/IpSecService.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server;
-
-import android.content.Context;
-import android.os.Binder;
-
-/**
- * Fake IpSecManager class for sc-mainline-prod,
- * to allow building the T service-connectivity before sources
- * are moved to the branch
- */
-public final class IpSecService extends Binder {
-    public IpSecService(Context ctx) {
-        throw new RuntimeException("This is a stub class");
-    }
-}
diff --git a/buildstubs-t/stubs-src/com/android/server/NsdService.java b/buildstubs-t/stubs-src/com/android/server/NsdService.java
deleted file mode 100644
index 4a3ba90..0000000
--- a/buildstubs-t/stubs-src/com/android/server/NsdService.java
+++ /dev/null
@@ -1,32 +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;
-
-import android.content.Context;
-import android.os.Binder;
-
-/**
- * Fake NsdService class for sc-mainline-prod,
- * to allow building the T service-connectivity before sources
- * are moved to the branch
- */
-public final class NsdService extends Binder {
-    /** Create instance */
-    public static NsdService create(Context ctx) throws InterruptedException {
-        throw new RuntimeException("This is a stub class");
-    }
-}
diff --git a/buildstubs-t/stubs-src/com/android/server/net/NetworkStatsService.java b/buildstubs-t/stubs-src/com/android/server/net/NetworkStatsService.java
deleted file mode 100644
index 8568e2a..0000000
--- a/buildstubs-t/stubs-src/com/android/server/net/NetworkStatsService.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.net;
-
-import android.content.Context;
-import android.os.Binder;
-
-/**
- * Fake NetworkStatsService class for sc-mainline-prod,
- * to allow building the T service-connectivity before sources
- * are moved to the branch
- */
-public final class NetworkStatsService extends Binder {
-    /** Create instance */
-    public static NetworkStatsService create(Context ctx) {
-        throw new RuntimeException("This is a stub class");
-    }
-
-    /** System Ready */
-    public void systemReady() {
-        throw new RuntimeException("This is a stub class");
-    }
-}
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index 292dc3c..9c8b359 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -19,9 +19,12 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+// Include build rules from Sources.bp
+build = ["Sources.bp"]
+
 java_defaults {
     name: "enable-framework-connectivity-t-targets",
-    enabled: false,
+    enabled: true,
 }
 // The above defaults can be used to disable framework-connectivity t
 // targets while minimizing merge conflicts in the build rules.
@@ -114,7 +117,7 @@
         // In preparation for future move
         "//packages/modules/Connectivity/apex",
         "//packages/modules/Connectivity/service-t",
-        "//packages/modules/Nearby/service",
+        "//packages/modules/Connectivity/nearby/service",
         "//frameworks/base",
 
         // Tests using hidden APIs
@@ -131,10 +134,10 @@
         "//frameworks/opt/telephony/tests/telephonytests",
         "//packages/modules/CaptivePortalLogin/tests",
         "//packages/modules/Connectivity/Tethering/tests:__subpackages__",
+        "//packages/modules/Connectivity/nearby/tests:__subpackages__",
         "//packages/modules/Connectivity/tests:__subpackages__",
         "//packages/modules/IPsec/tests/iketests",
         "//packages/modules/NetworkStack/tests:__subpackages__",
-        "//packages/modules/Nearby/tests:__subpackages__",
         "//packages/modules/Wifi/service/tests/wifitests",
     ],
 }
diff --git a/framework-t/Sources.bp b/framework-t/Sources.bp
new file mode 100644
index 0000000..53b4163
--- /dev/null
+++ b/framework-t/Sources.bp
@@ -0,0 +1,175 @@
+//
+// 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.
+//
+
+// NetworkStats related libraries.
+
+filegroup {
+    name: "framework-connectivity-netstats-internal-sources",
+    srcs: [
+        "src/android/app/usage/*.java",
+        "src/android/net/DataUsageRequest.*",
+        "src/android/net/INetworkStatsService.aidl",
+        "src/android/net/INetworkStatsSession.aidl",
+        "src/android/net/NetworkIdentity.java",
+        "src/android/net/NetworkIdentitySet.java",
+        "src/android/net/NetworkStateSnapshot.*",
+        "src/android/net/NetworkStats.*",
+        "src/android/net/NetworkStatsAccess.*",
+        "src/android/net/NetworkStatsCollection.*",
+        "src/android/net/NetworkStatsHistory.*",
+        "src/android/net/NetworkTemplate.*",
+        "src/android/net/TrafficStats.java",
+        "src/android/net/UnderlyingNetworkInfo.*",
+        "src/android/net/netstats/**/*.*",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+filegroup {
+    name: "framework-connectivity-netstats-sources",
+    srcs: [
+        ":framework-connectivity-netstats-internal-sources",
+    ],
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// Nsd related libraries.
+
+filegroup {
+    name: "framework-connectivity-nsd-internal-sources",
+    srcs: [
+        "src/android/net/nsd/*.aidl",
+        "src/android/net/nsd/*.java",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+filegroup {
+    name: "framework-connectivity-nsd-sources",
+    srcs: [
+        ":framework-connectivity-nsd-internal-sources",
+    ],
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// IpSec related libraries.
+
+filegroup {
+    name: "framework-connectivity-ipsec-sources",
+    srcs: [
+        "src/android/net/IIpSecService.aidl",
+        "src/android/net/IpSec*.*",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// Ethernet related libraries.
+
+filegroup {
+    name: "framework-connectivity-ethernet-sources",
+    srcs: [
+        "src/android/net/EthernetManager.java",
+        "src/android/net/EthernetNetworkManagementException.java",
+        "src/android/net/EthernetNetworkManagementException.aidl",
+        "src/android/net/EthernetNetworkSpecifier.java",
+        "src/android/net/EthernetNetworkUpdateRequest.java",
+        "src/android/net/EthernetNetworkUpdateRequest.aidl",
+        "src/android/net/IEthernetManager.aidl",
+        "src/android/net/IEthernetServiceListener.aidl",
+        "src/android/net/INetworkInterfaceOutcomeReceiver.aidl",
+        "src/android/net/ITetheredInterfaceCallback.aidl",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// Connectivity-T common libraries.
+
+filegroup {
+    name: "framework-connectivity-tiramisu-internal-sources",
+    srcs: [
+        "src/android/net/ConnectivityFrameworkInitializerTiramisu.java",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// TODO: remove this empty filegroup.
+filegroup {
+    name: "framework-connectivity-tiramisu-sources",
+    srcs: [],
+    visibility: ["//frameworks/base"],
+}
+
+filegroup {
+    name: "framework-connectivity-tiramisu-updatable-sources",
+    srcs: [
+        ":framework-connectivity-ethernet-sources",
+        ":framework-connectivity-ipsec-sources",
+        ":framework-connectivity-netstats-sources",
+        ":framework-connectivity-nsd-sources",
+        ":framework-connectivity-tiramisu-internal-sources",
+    ],
+    visibility: [
+        "//frameworks/base",
+        "//packages/modules/Connectivity:__subpackages__",
+    ],
+}
+
+cc_library_shared {
+    name: "libframework-connectivity-tiramisu-jni",
+    min_sdk_version: "30",
+    cflags: [
+        "-Wall",
+        "-Werror",
+        "-Wno-unused-parameter",
+        // Don't warn about S API usage even with
+        // min_sdk 30: the library is only loaded
+        // on S+ devices
+        "-Wno-unguarded-availability",
+        "-Wthread-safety",
+    ],
+    srcs: [
+        "jni/android_net_TrafficStats.cpp",
+        "jni/onload.cpp",
+    ],
+    shared_libs: [
+        "libandroid",
+        "liblog",
+        "libnativehelper",
+    ],
+    stl: "none",
+    apex_available: [
+        "com.android.tethering",
+    ],
+}
diff --git a/framework-t/api/current.txt b/framework-t/api/current.txt
index 1b47481..eb77288 100644
--- a/framework-t/api/current.txt
+++ b/framework-t/api/current.txt
@@ -3,7 +3,7 @@
 
   public final class NetworkStats implements java.lang.AutoCloseable {
     method public void close();
-    method public boolean getNextBucket(android.app.usage.NetworkStats.Bucket);
+    method public boolean getNextBucket(@Nullable android.app.usage.NetworkStats.Bucket);
     method public boolean hasNextBucket();
   }
 
@@ -40,21 +40,21 @@
   }
 
   public class NetworkStatsManager {
-    method @WorkerThread public android.app.usage.NetworkStats queryDetails(int, String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
-    method @WorkerThread public android.app.usage.NetworkStats queryDetailsForUid(int, String, long, long, int) throws java.lang.SecurityException;
-    method @WorkerThread public android.app.usage.NetworkStats queryDetailsForUidTag(int, String, long, long, int, int) throws java.lang.SecurityException;
-    method @WorkerThread public android.app.usage.NetworkStats queryDetailsForUidTagState(int, String, long, long, int, int, int) throws java.lang.SecurityException;
-    method @WorkerThread public android.app.usage.NetworkStats querySummary(int, String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
-    method @WorkerThread public android.app.usage.NetworkStats.Bucket querySummaryForDevice(int, String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
-    method @WorkerThread public android.app.usage.NetworkStats.Bucket querySummaryForUser(int, String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
-    method public void registerUsageCallback(int, String, long, android.app.usage.NetworkStatsManager.UsageCallback);
-    method public void registerUsageCallback(int, String, long, android.app.usage.NetworkStatsManager.UsageCallback, @Nullable android.os.Handler);
-    method public void unregisterUsageCallback(android.app.usage.NetworkStatsManager.UsageCallback);
+    method @WorkerThread public android.app.usage.NetworkStats queryDetails(int, @Nullable String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
+    method @NonNull @WorkerThread public android.app.usage.NetworkStats queryDetailsForUid(int, @Nullable String, long, long, int) throws java.lang.SecurityException;
+    method @NonNull @WorkerThread public android.app.usage.NetworkStats queryDetailsForUidTag(int, @Nullable String, long, long, int, int) throws java.lang.SecurityException;
+    method @NonNull @WorkerThread public android.app.usage.NetworkStats queryDetailsForUidTagState(int, @Nullable String, long, long, int, int, int) throws java.lang.SecurityException;
+    method @WorkerThread public android.app.usage.NetworkStats querySummary(int, @Nullable String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
+    method @WorkerThread public android.app.usage.NetworkStats.Bucket querySummaryForDevice(int, @Nullable String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
+    method @WorkerThread public android.app.usage.NetworkStats.Bucket querySummaryForUser(int, @Nullable String, long, long) throws android.os.RemoteException, java.lang.SecurityException;
+    method public void registerUsageCallback(int, @Nullable String, long, @NonNull android.app.usage.NetworkStatsManager.UsageCallback);
+    method public void registerUsageCallback(int, @Nullable String, long, @NonNull android.app.usage.NetworkStatsManager.UsageCallback, @Nullable android.os.Handler);
+    method public void unregisterUsageCallback(@NonNull android.app.usage.NetworkStatsManager.UsageCallback);
   }
 
   public abstract static class NetworkStatsManager.UsageCallback {
     ctor public NetworkStatsManager.UsageCallback();
-    method public abstract void onThresholdReached(int, String);
+    method public abstract void onThresholdReached(int, @Nullable String);
   }
 
 }
@@ -173,12 +173,12 @@
     method public static void incrementOperationCount(int, int);
     method public static void setThreadStatsTag(int);
     method public static void setThreadStatsUid(int);
-    method public static void tagDatagramSocket(java.net.DatagramSocket) throws java.net.SocketException;
-    method public static void tagFileDescriptor(java.io.FileDescriptor) throws java.io.IOException;
-    method public static void tagSocket(java.net.Socket) throws java.net.SocketException;
-    method public static void untagDatagramSocket(java.net.DatagramSocket) throws java.net.SocketException;
-    method public static void untagFileDescriptor(java.io.FileDescriptor) throws java.io.IOException;
-    method public static void untagSocket(java.net.Socket) throws java.net.SocketException;
+    method public static void tagDatagramSocket(@NonNull java.net.DatagramSocket) throws java.net.SocketException;
+    method public static void tagFileDescriptor(@NonNull java.io.FileDescriptor) throws java.io.IOException;
+    method public static void tagSocket(@NonNull java.net.Socket) throws java.net.SocketException;
+    method public static void untagDatagramSocket(@NonNull java.net.DatagramSocket) throws java.net.SocketException;
+    method public static void untagFileDescriptor(@NonNull java.io.FileDescriptor) throws java.io.IOException;
+    method public static void untagSocket(@NonNull java.net.Socket) throws java.net.SocketException;
     field public static final int UNSUPPORTED = -1; // 0xffffffff
   }
 
diff --git a/framework-t/api/lint-baseline.txt b/framework-t/api/lint-baseline.txt
index 53e1beb..2996a3e 100644
--- a/framework-t/api/lint-baseline.txt
+++ b/framework-t/api/lint-baseline.txt
@@ -41,86 +41,18 @@
     android.net.IpSecTransform.Builder does not declare a `build()` method, but builder classes are expected to
 
 
-MissingNullability: android.app.usage.NetworkStats#getNextBucket(android.app.usage.NetworkStats.Bucket) parameter #0:
-    Missing nullability on parameter `bucketOut` in method `getNextBucket`
 MissingNullability: android.app.usage.NetworkStatsManager#queryDetails(int, String, long, long):
     Missing nullability on method `queryDetails` return
-MissingNullability: android.app.usage.NetworkStatsManager#queryDetails(int, String, long, long) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `queryDetails`
-MissingNullability: android.app.usage.NetworkStatsManager#queryDetailsForUid(int, String, long, long, int):
-    Missing nullability on method `queryDetailsForUid` return
-MissingNullability: android.app.usage.NetworkStatsManager#queryDetailsForUid(int, String, long, long, int) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `queryDetailsForUid`
-MissingNullability: android.app.usage.NetworkStatsManager#queryDetailsForUidTag(int, String, long, long, int, int):
-    Missing nullability on method `queryDetailsForUidTag` return
-MissingNullability: android.app.usage.NetworkStatsManager#queryDetailsForUidTag(int, String, long, long, int, int) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `queryDetailsForUidTag`
-MissingNullability: android.app.usage.NetworkStatsManager#queryDetailsForUidTagState(int, String, long, long, int, int, int):
-    Missing nullability on method `queryDetailsForUidTagState` return
-MissingNullability: android.app.usage.NetworkStatsManager#queryDetailsForUidTagState(int, String, long, long, int, int, int) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `queryDetailsForUidTagState`
 MissingNullability: android.app.usage.NetworkStatsManager#querySummary(int, String, long, long):
     Missing nullability on method `querySummary` return
-MissingNullability: android.app.usage.NetworkStatsManager#querySummary(int, String, long, long) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `querySummary`
 MissingNullability: android.app.usage.NetworkStatsManager#querySummaryForDevice(int, String, long, long):
     Missing nullability on method `querySummaryForDevice` return
-MissingNullability: android.app.usage.NetworkStatsManager#querySummaryForDevice(int, String, long, long) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `querySummaryForDevice`
 MissingNullability: android.app.usage.NetworkStatsManager#querySummaryForUser(int, String, long, long):
     Missing nullability on method `querySummaryForUser` return
-MissingNullability: android.app.usage.NetworkStatsManager#querySummaryForUser(int, String, long, long) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `querySummaryForUser`
-MissingNullability: android.app.usage.NetworkStatsManager#registerUsageCallback(int, String, long, android.app.usage.NetworkStatsManager.UsageCallback) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `registerUsageCallback`
-MissingNullability: android.app.usage.NetworkStatsManager#registerUsageCallback(int, String, long, android.app.usage.NetworkStatsManager.UsageCallback) parameter #3:
-    Missing nullability on parameter `callback` in method `registerUsageCallback`
-MissingNullability: android.app.usage.NetworkStatsManager#registerUsageCallback(int, String, long, android.app.usage.NetworkStatsManager.UsageCallback, android.os.Handler) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `registerUsageCallback`
-MissingNullability: android.app.usage.NetworkStatsManager#registerUsageCallback(int, String, long, android.app.usage.NetworkStatsManager.UsageCallback, android.os.Handler) parameter #3:
-    Missing nullability on parameter `callback` in method `registerUsageCallback`
-MissingNullability: android.app.usage.NetworkStatsManager#unregisterUsageCallback(android.app.usage.NetworkStatsManager.UsageCallback) parameter #0:
-    Missing nullability on parameter `callback` in method `unregisterUsageCallback`
-MissingNullability: android.app.usage.NetworkStatsManager.UsageCallback#onThresholdReached(int, String) parameter #1:
-    Missing nullability on parameter `subscriberId` in method `onThresholdReached`
 MissingNullability: android.net.IpSecAlgorithm#writeToParcel(android.os.Parcel, int) parameter #0:
     Missing nullability on parameter `out` in method `writeToParcel`
 MissingNullability: android.net.IpSecManager.UdpEncapsulationSocket#getFileDescriptor():
     Missing nullability on method `getFileDescriptor` return
-MissingNullability: android.net.TrafficStats#tagDatagramSocket(java.net.DatagramSocket) parameter #0:
-    Missing nullability on parameter `socket` in method `tagDatagramSocket`
-MissingNullability: android.net.TrafficStats#tagFileDescriptor(java.io.FileDescriptor) parameter #0:
-    Missing nullability on parameter `fd` in method `tagFileDescriptor`
-MissingNullability: android.net.TrafficStats#tagSocket(java.net.Socket) parameter #0:
-    Missing nullability on parameter `socket` in method `tagSocket`
-MissingNullability: android.net.TrafficStats#untagDatagramSocket(java.net.DatagramSocket) parameter #0:
-    Missing nullability on parameter `socket` in method `untagDatagramSocket`
-MissingNullability: android.net.TrafficStats#untagFileDescriptor(java.io.FileDescriptor) parameter #0:
-    Missing nullability on parameter `fd` in method `untagFileDescriptor`
-MissingNullability: android.net.TrafficStats#untagSocket(java.net.Socket) parameter #0:
-    Missing nullability on parameter `socket` in method `untagSocket`
-MissingNullability: com.android.internal.util.FileRotator#FileRotator(java.io.File, String, long, long) parameter #0:
-    Missing nullability on parameter `basePath` in method `FileRotator`
-MissingNullability: com.android.internal.util.FileRotator#FileRotator(java.io.File, String, long, long) parameter #1:
-    Missing nullability on parameter `prefix` in method `FileRotator`
-MissingNullability: com.android.internal.util.FileRotator#dumpAll(java.io.OutputStream) parameter #0:
-    Missing nullability on parameter `os` in method `dumpAll`
-MissingNullability: com.android.internal.util.FileRotator#readMatching(com.android.internal.util.FileRotator.Reader, long, long) parameter #0:
-    Missing nullability on parameter `reader` in method `readMatching`
-MissingNullability: com.android.internal.util.FileRotator#rewriteActive(com.android.internal.util.FileRotator.Rewriter, long) parameter #0:
-    Missing nullability on parameter `rewriter` in method `rewriteActive`
-MissingNullability: com.android.internal.util.FileRotator#rewriteAll(com.android.internal.util.FileRotator.Rewriter) parameter #0:
-    Missing nullability on parameter `rewriter` in method `rewriteAll`
-MissingNullability: com.android.internal.util.FileRotator.Reader#read(java.io.InputStream) parameter #0:
-    Missing nullability on parameter `in` in method `read`
-MissingNullability: com.android.internal.util.FileRotator.Writer#write(java.io.OutputStream) parameter #0:
-    Missing nullability on parameter `out` in method `write`
-MissingNullability: com.android.server.NetworkManagementSocketTagger#kernelToTag(String) parameter #0:
-    Missing nullability on parameter `string` in method `kernelToTag`
-MissingNullability: com.android.server.NetworkManagementSocketTagger#tag(java.io.FileDescriptor) parameter #0:
-    Missing nullability on parameter `fd` in method `tag`
-MissingNullability: com.android.server.NetworkManagementSocketTagger#untag(java.io.FileDescriptor) parameter #0:
-    Missing nullability on parameter `fd` in method `untag`
 
 
 RethrowRemoteException: android.app.usage.NetworkStatsManager#queryDetails(int, String, long, long):
diff --git a/framework-t/api/module-lib-current.txt b/framework-t/api/module-lib-current.txt
index 658c625..216081a 100644
--- a/framework-t/api/module-lib-current.txt
+++ b/framework-t/api/module-lib-current.txt
@@ -4,6 +4,8 @@
   public class NetworkStatsManager {
     method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public void forceUpdate();
     method public static int getCollapsedRatType(int);
+    method @NonNull @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public android.net.NetworkStats getMobileUidStats();
+    method @NonNull @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public android.net.NetworkStats getWifiUidStats();
     method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public void noteUidForeground(int, boolean);
     method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public void notifyNetworkStatus(@NonNull java.util.List<android.net.Network>, @NonNull java.util.List<android.net.NetworkStateSnapshot>, @Nullable String, @NonNull java.util.List<android.net.UnderlyingNetworkInfo>);
     method @NonNull @WorkerThread public android.app.usage.NetworkStats queryDetailsForDevice(@NonNull android.net.NetworkTemplate, long, long);
@@ -25,6 +27,14 @@
 
 }
 
+package android.nearby {
+
+  public final class NearbyFrameworkInitializer {
+    method public static void registerServiceWrappers();
+  }
+
+}
+
 package android.net {
 
   public final class ConnectivityFrameworkInitializerTiramisu {
@@ -102,6 +112,24 @@
     field @NonNull public static final android.os.Parcelable.Creator<android.net.NetworkStateSnapshot> CREATOR;
   }
 
+  public final class NetworkStats implements java.lang.Iterable<android.net.NetworkStats.Entry> android.os.Parcelable {
+    method @NonNull public java.util.Iterator<android.net.NetworkStats.Entry> iterator();
+  }
+
+  public static class NetworkStats.Entry {
+    method public int getDefaultNetwork();
+    method public int getMetered();
+    method public long getOperations();
+    method public int getRoaming();
+    method public long getRxBytes();
+    method public long getRxPackets();
+    method public int getSet();
+    method public int getTag();
+    method public long getTxBytes();
+    method public long getTxPackets();
+    method public int getUid();
+  }
+
   public class NetworkStatsCollection {
     method @NonNull public java.util.Map<android.net.NetworkStatsCollection.Key,android.net.NetworkStatsHistory> getEntries();
   }
@@ -182,6 +210,7 @@
   public class TrafficStats {
     method public static void attachSocketTagger();
     method public static void init(@NonNull android.content.Context);
+    method public static void setThreadStatsTagDownload();
   }
 
   public final class UnderlyingNetworkInfo implements android.os.Parcelable {
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index 0f37b6f..ddfd30c 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -2,10 +2,463 @@
 package android.app.usage {
 
   public class NetworkStatsManager {
-    method @NonNull @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public android.net.NetworkStats getMobileUidStats();
-    method @NonNull @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK}) public android.net.NetworkStats getWifiUidStats();
-    method @NonNull @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_STATS_PROVIDER, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void registerNetworkStatsProvider(@NonNull String, @NonNull android.net.netstats.provider.NetworkStatsProvider);
-    method @NonNull @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_STATS_PROVIDER, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void unregisterNetworkStatsProvider(@NonNull android.net.netstats.provider.NetworkStatsProvider);
+    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_STATS_PROVIDER, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void registerNetworkStatsProvider(@NonNull String, @NonNull android.net.netstats.provider.NetworkStatsProvider);
+    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_STATS_PROVIDER, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void unregisterNetworkStatsProvider(@NonNull android.net.netstats.provider.NetworkStatsProvider);
+  }
+
+}
+
+package android.nearby {
+
+  public interface BroadcastCallback {
+    method public void onStatusChanged(int);
+    field public static final int STATUS_FAILURE = 1; // 0x1
+    field public static final int STATUS_FAILURE_ALREADY_REGISTERED = 2; // 0x2
+    field public static final int STATUS_FAILURE_MISSING_PERMISSIONS = 4; // 0x4
+    field public static final int STATUS_FAILURE_SIZE_EXCEED_LIMIT = 3; // 0x3
+    field public static final int STATUS_OK = 0; // 0x0
+  }
+
+  public abstract class BroadcastRequest {
+    method @NonNull public java.util.List<java.lang.Integer> getMediums();
+    method @IntRange(from=0xffffff81, to=126) public int getTxPower();
+    method public int getType();
+    method public int getVersion();
+    field public static final int BROADCAST_TYPE_NEARBY_PRESENCE = 3; // 0x3
+    field public static final int BROADCAST_TYPE_UNKNOWN = -1; // 0xffffffff
+    field public static final int PRESENCE_VERSION_UNKNOWN = -1; // 0xffffffff
+    field public static final int PRESENCE_VERSION_V0 = 0; // 0x0
+    field public static final int PRESENCE_VERSION_V1 = 1; // 0x1
+    field public static final int UNKNOWN_TX_POWER = -127; // 0xffffff81
+  }
+
+  public final class CredentialElement implements android.os.Parcelable {
+    ctor public CredentialElement(@NonNull String, @NonNull byte[]);
+    method public int describeContents();
+    method @NonNull public String getKey();
+    method @NonNull public byte[] getValue();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.CredentialElement> CREATOR;
+  }
+
+  public final class DataElement implements android.os.Parcelable {
+    ctor public DataElement(int, @NonNull byte[]);
+    method public int describeContents();
+    method public int getKey();
+    method @NonNull public byte[] getValue();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.DataElement> CREATOR;
+  }
+
+  public class FastPairAccountKeyDeviceMetadata {
+    method @Nullable public byte[] getDeviceAccountKey();
+    method @Nullable public android.nearby.FastPairDeviceMetadata getFastPairDeviceMetadata();
+    method @Nullable public android.nearby.FastPairDiscoveryItem getFastPairDiscoveryItem();
+    method @Nullable public byte[] getSha256DeviceAccountKeyPublicAddress();
+  }
+
+  public static final class FastPairAccountKeyDeviceMetadata.Builder {
+    ctor public FastPairAccountKeyDeviceMetadata.Builder();
+    method @NonNull public android.nearby.FastPairAccountKeyDeviceMetadata build();
+    method @NonNull public android.nearby.FastPairAccountKeyDeviceMetadata.Builder setDeviceAccountKey(@Nullable byte[]);
+    method @NonNull public android.nearby.FastPairAccountKeyDeviceMetadata.Builder setFastPairDeviceMetadata(@Nullable android.nearby.FastPairDeviceMetadata);
+    method @NonNull public android.nearby.FastPairAccountKeyDeviceMetadata.Builder setFastPairDiscoveryItem(@Nullable android.nearby.FastPairDiscoveryItem);
+    method @NonNull public android.nearby.FastPairAccountKeyDeviceMetadata.Builder setSha256DeviceAccountKeyPublicAddress(@Nullable byte[]);
+  }
+
+  public class FastPairAntispoofKeyDeviceMetadata {
+    method @Nullable public byte[] getAntispoofPublicKey();
+    method @Nullable public android.nearby.FastPairDeviceMetadata getFastPairDeviceMetadata();
+  }
+
+  public static final class FastPairAntispoofKeyDeviceMetadata.Builder {
+    ctor public FastPairAntispoofKeyDeviceMetadata.Builder();
+    method @NonNull public android.nearby.FastPairAntispoofKeyDeviceMetadata build();
+    method @NonNull public android.nearby.FastPairAntispoofKeyDeviceMetadata.Builder setAntispoofPublicKey(@Nullable byte[]);
+    method @NonNull public android.nearby.FastPairAntispoofKeyDeviceMetadata.Builder setFastPairDeviceMetadata(@Nullable android.nearby.FastPairDeviceMetadata);
+  }
+
+  public abstract class FastPairDataProviderService extends android.app.Service {
+    ctor public FastPairDataProviderService(@NonNull String);
+    method @NonNull public final android.os.IBinder onBind(@NonNull android.content.Intent);
+    method public abstract void onLoadFastPairAccountDevicesMetadata(@NonNull android.nearby.FastPairDataProviderService.FastPairAccountDevicesMetadataRequest, @NonNull android.nearby.FastPairDataProviderService.FastPairAccountDevicesMetadataCallback);
+    method public abstract void onLoadFastPairAntispoofKeyDeviceMetadata(@NonNull android.nearby.FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataRequest, @NonNull android.nearby.FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataCallback);
+    method public abstract void onLoadFastPairEligibleAccounts(@NonNull android.nearby.FastPairDataProviderService.FastPairEligibleAccountsRequest, @NonNull android.nearby.FastPairDataProviderService.FastPairEligibleAccountsCallback);
+    method public abstract void onManageFastPairAccount(@NonNull android.nearby.FastPairDataProviderService.FastPairManageAccountRequest, @NonNull android.nearby.FastPairDataProviderService.FastPairManageActionCallback);
+    method public abstract void onManageFastPairAccountDevice(@NonNull android.nearby.FastPairDataProviderService.FastPairManageAccountDeviceRequest, @NonNull android.nearby.FastPairDataProviderService.FastPairManageActionCallback);
+    field public static final String ACTION_FAST_PAIR_DATA_PROVIDER = "android.nearby.action.FAST_PAIR_DATA_PROVIDER";
+    field public static final int ERROR_CODE_BAD_REQUEST = 0; // 0x0
+    field public static final int ERROR_CODE_INTERNAL_ERROR = 1; // 0x1
+    field public static final int MANAGE_REQUEST_ADD = 0; // 0x0
+    field public static final int MANAGE_REQUEST_REMOVE = 1; // 0x1
+  }
+
+  public static interface FastPairDataProviderService.FastPairAccountDevicesMetadataCallback {
+    method public void onError(int, @Nullable String);
+    method public void onFastPairAccountDevicesMetadataReceived(@NonNull java.util.Collection<android.nearby.FastPairAccountKeyDeviceMetadata>);
+  }
+
+  public static class FastPairDataProviderService.FastPairAccountDevicesMetadataRequest {
+    method @NonNull public android.accounts.Account getAccount();
+    method @NonNull public java.util.Collection<byte[]> getDeviceAccountKeys();
+  }
+
+  public static interface FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataCallback {
+    method public void onError(int, @Nullable String);
+    method public void onFastPairAntispoofKeyDeviceMetadataReceived(@NonNull android.nearby.FastPairAntispoofKeyDeviceMetadata);
+  }
+
+  public static class FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataRequest {
+    method @NonNull public byte[] getModelId();
+  }
+
+  public static interface FastPairDataProviderService.FastPairEligibleAccountsCallback {
+    method public void onError(int, @Nullable String);
+    method public void onFastPairEligibleAccountsReceived(@NonNull java.util.Collection<android.nearby.FastPairEligibleAccount>);
+  }
+
+  public static class FastPairDataProviderService.FastPairEligibleAccountsRequest {
+  }
+
+  public static class FastPairDataProviderService.FastPairManageAccountDeviceRequest {
+    method @NonNull public android.accounts.Account getAccount();
+    method @NonNull public android.nearby.FastPairAccountKeyDeviceMetadata getAccountKeyDeviceMetadata();
+    method @Nullable public String getBleAddress();
+    method public int getRequestType();
+  }
+
+  public static class FastPairDataProviderService.FastPairManageAccountRequest {
+    method @NonNull public android.accounts.Account getAccount();
+    method public int getRequestType();
+  }
+
+  public static interface FastPairDataProviderService.FastPairManageActionCallback {
+    method public void onError(int, @Nullable String);
+    method public void onSuccess();
+  }
+
+  public class FastPairDeviceMetadata {
+    method @Nullable public String getAssistantSetupHalfSheet();
+    method @Nullable public String getAssistantSetupNotification();
+    method public int getBleTxPower();
+    method @Nullable public String getConfirmPinDescription();
+    method @Nullable public String getConfirmPinTitle();
+    method @Nullable public String getConnectSuccessCompanionAppInstalled();
+    method @Nullable public String getConnectSuccessCompanionAppNotInstalled();
+    method public int getDeviceType();
+    method @Nullable public String getDownloadCompanionAppDescription();
+    method @Nullable public String getFailConnectGoToSettingsDescription();
+    method @Nullable public String getFastPairTvConnectDeviceNoAccountDescription();
+    method @Nullable public byte[] getImage();
+    method @Nullable public String getImageUrl();
+    method @Nullable public String getInitialNotificationDescription();
+    method @Nullable public String getInitialNotificationDescriptionNoAccount();
+    method @Nullable public String getInitialPairingDescription();
+    method @Nullable public String getIntentUri();
+    method @Nullable public String getLocale();
+    method @Nullable public String getName();
+    method @Nullable public String getOpenCompanionAppDescription();
+    method @Nullable public String getRetroactivePairingDescription();
+    method @Nullable public String getSubsequentPairingDescription();
+    method @Nullable public String getSyncContactsDescription();
+    method @Nullable public String getSyncContactsTitle();
+    method @Nullable public String getSyncSmsDescription();
+    method @Nullable public String getSyncSmsTitle();
+    method public float getTriggerDistance();
+    method @Nullable public String getTrueWirelessImageUrlCase();
+    method @Nullable public String getTrueWirelessImageUrlLeftBud();
+    method @Nullable public String getTrueWirelessImageUrlRightBud();
+    method @Nullable public String getUnableToConnectDescription();
+    method @Nullable public String getUnableToConnectTitle();
+    method @Nullable public String getUpdateCompanionAppDescription();
+    method @Nullable public String getWaitLaunchCompanionAppDescription();
+  }
+
+  public static final class FastPairDeviceMetadata.Builder {
+    ctor public FastPairDeviceMetadata.Builder();
+    method @NonNull public android.nearby.FastPairDeviceMetadata build();
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setAssistantSetupHalfSheet(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setAssistantSetupNotification(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setBleTxPower(int);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setConfirmPinDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setConfirmPinTitle(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setConnectSuccessCompanionAppInstalled(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setConnectSuccessCompanionAppNotInstalled(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setDeviceType(int);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setDownloadCompanionAppDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setFailConnectGoToSettingsDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setFastPairTvConnectDeviceNoAccountDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setImage(@Nullable byte[]);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setImageUrl(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setInitialNotificationDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setInitialNotificationDescriptionNoAccount(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setInitialPairingDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setIntentUri(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setLocale(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setName(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setOpenCompanionAppDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setRetroactivePairingDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setSubsequentPairingDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setSyncContactsDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setSyncContactsTitle(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setSyncSmsDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setSyncSmsTitle(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setTriggerDistance(float);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setTrueWirelessImageUrlCase(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setTrueWirelessImageUrlLeftBud(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setTrueWirelessImageUrlRightBud(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setUnableToConnectDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setUnableToConnectTitle(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setUpdateCompanionAppDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDeviceMetadata.Builder setWaitLaunchCompanionAppDescription(@Nullable String);
+  }
+
+  public class FastPairDiscoveryItem {
+    method @Nullable public String getActionUrl();
+    method public int getActionUrlType();
+    method @Nullable public String getAppName();
+    method public int getAttachmentType();
+    method @Nullable public byte[] getAuthenticationPublicKeySecp256r1();
+    method @Nullable public byte[] getBleRecordBytes();
+    method public int getDebugCategory();
+    method @Nullable public String getDebugMessage();
+    method @Nullable public String getDescription();
+    method @Nullable public String getDeviceName();
+    method @Nullable public String getDisplayUrl();
+    method @Nullable public String getEntityId();
+    method @Nullable public String getFeatureGraphicUrl();
+    method public long getFirstObservationTimestampMillis();
+    method @Nullable public String getGroupId();
+    method @Nullable public String getIconFfeUrl();
+    method @Nullable public byte[] getIconPng();
+    method @Nullable public String getId();
+    method public long getLastObservationTimestampMillis();
+    method public int getLastUserExperience();
+    method public long getLostMillis();
+    method @Nullable public String getMacAddress();
+    method @Nullable public String getPackageName();
+    method public long getPendingAppInstallTimestampMillis();
+    method public int getRssi();
+    method public int getState();
+    method @Nullable public String getTitle();
+    method @Nullable public String getTriggerId();
+    method public int getTxPower();
+    method public int getType();
+  }
+
+  public static final class FastPairDiscoveryItem.Builder {
+    ctor public FastPairDiscoveryItem.Builder();
+    method @NonNull public android.nearby.FastPairDiscoveryItem build();
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setActionUrl(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setActionUrlType(int);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setAppName(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setAttachmentType(int);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setAuthenticationPublicKeySecp256r1(@Nullable byte[]);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setBleRecordBytes(@Nullable byte[]);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setDebugCategory(int);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setDebugMessage(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setDescription(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setDeviceName(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setDisplayUrl(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setEntityId(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setFeatureGraphicUrl(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setFirstObservationTimestampMillis(long);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setGroupId(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setIconFfeUrl(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setIconPng(@Nullable byte[]);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setId(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setLastObservationTimestampMillis(long);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setLastUserExperience(int);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setLostMillis(long);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setMacAddress(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setPackageName(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setPendingAppInstallTimestampMillis(long);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setRssi(int);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setState(int);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setTitle(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setTriggerId(@Nullable String);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setTxPower(int);
+    method @NonNull public android.nearby.FastPairDiscoveryItem.Builder setType(int);
+  }
+
+  public class FastPairEligibleAccount {
+    method @Nullable public android.accounts.Account getAccount();
+    method public boolean isOptIn();
+  }
+
+  public static final class FastPairEligibleAccount.Builder {
+    ctor public FastPairEligibleAccount.Builder();
+    method @NonNull public android.nearby.FastPairEligibleAccount build();
+    method @NonNull public android.nearby.FastPairEligibleAccount.Builder setAccount(@Nullable android.accounts.Account);
+    method @NonNull public android.nearby.FastPairEligibleAccount.Builder setOptIn(boolean);
+  }
+
+  public abstract class NearbyDevice {
+    method @NonNull public java.util.List<java.lang.Integer> getMediums();
+    method @Nullable public String getName();
+    method @IntRange(from=0xffffff81, to=126) public int getRssi();
+    method public static boolean isValidMedium(int);
+  }
+
+  public class NearbyManager {
+    method public static boolean getFastPairScanEnabled(@NonNull android.content.Context);
+    method public static void setFastPairScanEnabled(@NonNull android.content.Context, boolean);
+    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 PresenceBroadcastRequest extends android.nearby.BroadcastRequest implements android.os.Parcelable {
+    method public int describeContents();
+    method @NonNull public java.util.List<java.lang.Integer> getActions();
+    method @NonNull public android.nearby.PrivateCredential getCredential();
+    method @NonNull public java.util.List<android.nearby.DataElement> getExtendedProperties();
+    method @NonNull public byte[] getSalt();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.PresenceBroadcastRequest> CREATOR;
+  }
+
+  public static final class PresenceBroadcastRequest.Builder {
+    ctor public PresenceBroadcastRequest.Builder(@NonNull java.util.List<java.lang.Integer>, @NonNull byte[], @NonNull android.nearby.PrivateCredential);
+    method @NonNull public android.nearby.PresenceBroadcastRequest.Builder addAction(@IntRange(from=1, to=255) int);
+    method @NonNull public android.nearby.PresenceBroadcastRequest.Builder addExtendedProperty(@NonNull android.nearby.DataElement);
+    method @NonNull public android.nearby.PresenceBroadcastRequest build();
+    method @NonNull public android.nearby.PresenceBroadcastRequest.Builder setTxPower(@IntRange(from=0xffffff81, to=126) int);
+    method @NonNull public android.nearby.PresenceBroadcastRequest.Builder setVersion(int);
+  }
+
+  public abstract class PresenceCredential {
+    method @NonNull public byte[] getAuthenticityKey();
+    method @NonNull public java.util.List<android.nearby.CredentialElement> getCredentialElements();
+    method public int getIdentityType();
+    method @NonNull public byte[] getSecretId();
+    method public int getType();
+    field public static final int CREDENTIAL_TYPE_PRIVATE = 0; // 0x0
+    field public static final int CREDENTIAL_TYPE_PUBLIC = 1; // 0x1
+    field public static final int IDENTITY_TYPE_PRIVATE = 1; // 0x1
+    field public static final int IDENTITY_TYPE_PROVISIONED = 2; // 0x2
+    field public static final int IDENTITY_TYPE_PUBLIC = 4; // 0x4
+    field public static final int IDENTITY_TYPE_TRUSTED = 3; // 0x3
+    field public static final int IDENTITY_TYPE_UNKNOWN = 0; // 0x0
+  }
+
+  public final class PresenceDevice extends android.nearby.NearbyDevice implements android.os.Parcelable {
+    method public int describeContents();
+    method @NonNull public String getDeviceId();
+    method @Nullable public String getDeviceImageUrl();
+    method public int getDeviceType();
+    method public long getDiscoveryTimestampMillis();
+    method @NonNull public byte[] getEncryptedIdentity();
+    method @NonNull public java.util.List<android.nearby.DataElement> getExtendedProperties();
+    method @NonNull public byte[] getSalt();
+    method @NonNull public byte[] getSecretId();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.PresenceDevice> CREATOR;
+  }
+
+  public static final class PresenceDevice.Builder {
+    ctor public PresenceDevice.Builder(@NonNull String, @NonNull byte[], @NonNull byte[], @NonNull byte[]);
+    method @NonNull public android.nearby.PresenceDevice.Builder addExtendedProperty(@NonNull android.nearby.DataElement);
+    method @NonNull public android.nearby.PresenceDevice.Builder addMedium(int);
+    method @NonNull public android.nearby.PresenceDevice build();
+    method @NonNull public android.nearby.PresenceDevice.Builder setDeviceImageUrl(@Nullable String);
+    method @NonNull public android.nearby.PresenceDevice.Builder setDeviceType(int);
+    method @NonNull public android.nearby.PresenceDevice.Builder setDiscoveryTimestampMillis(long);
+    method @NonNull public android.nearby.PresenceDevice.Builder setName(@Nullable String);
+    method @NonNull public android.nearby.PresenceDevice.Builder setRssi(int);
+  }
+
+  public final class PresenceScanFilter extends android.nearby.ScanFilter implements android.os.Parcelable {
+    method public int describeContents();
+    method @NonNull public java.util.List<android.nearby.PublicCredential> getCredentials();
+    method @NonNull public java.util.List<android.nearby.DataElement> getExtendedProperties();
+    method @NonNull public java.util.List<java.lang.Integer> getPresenceActions();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.PresenceScanFilter> CREATOR;
+  }
+
+  public static final class PresenceScanFilter.Builder {
+    ctor public PresenceScanFilter.Builder();
+    method @NonNull public android.nearby.PresenceScanFilter.Builder addCredential(@NonNull android.nearby.PublicCredential);
+    method @NonNull public android.nearby.PresenceScanFilter.Builder addExtendedProperty(@NonNull android.nearby.DataElement);
+    method @NonNull public android.nearby.PresenceScanFilter.Builder addPresenceAction(@IntRange(from=1, to=255) int);
+    method @NonNull public android.nearby.PresenceScanFilter build();
+    method @NonNull public android.nearby.PresenceScanFilter.Builder setMaxPathLoss(@IntRange(from=0, to=127) int);
+  }
+
+  public final class PrivateCredential extends android.nearby.PresenceCredential implements android.os.Parcelable {
+    method public int describeContents();
+    method @NonNull public String getDeviceName();
+    method @NonNull public byte[] getMetadataEncryptionKey();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.PrivateCredential> CREATOR;
+  }
+
+  public static final class PrivateCredential.Builder {
+    ctor public PrivateCredential.Builder(@NonNull byte[], @NonNull byte[], @NonNull byte[], @NonNull String);
+    method @NonNull public android.nearby.PrivateCredential.Builder addCredentialElement(@NonNull android.nearby.CredentialElement);
+    method @NonNull public android.nearby.PrivateCredential build();
+    method @NonNull public android.nearby.PrivateCredential.Builder setIdentityType(int);
+  }
+
+  public final class PublicCredential extends android.nearby.PresenceCredential implements android.os.Parcelable {
+    method public int describeContents();
+    method @NonNull public byte[] getEncryptedMetadata();
+    method @NonNull public byte[] getEncryptedMetadataKeyTag();
+    method @NonNull public byte[] getPublicKey();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.PublicCredential> CREATOR;
+  }
+
+  public static final class PublicCredential.Builder {
+    ctor public PublicCredential.Builder(@NonNull byte[], @NonNull byte[], @NonNull byte[], @NonNull byte[], @NonNull byte[]);
+    method @NonNull public android.nearby.PublicCredential.Builder addCredentialElement(@NonNull android.nearby.CredentialElement);
+    method @NonNull public android.nearby.PublicCredential build();
+    method @NonNull public android.nearby.PublicCredential.Builder setIdentityType(int);
+  }
+
+  public interface ScanCallback {
+    method public void onDiscovered(@NonNull android.nearby.NearbyDevice);
+    method public void onLost(@NonNull android.nearby.NearbyDevice);
+    method public void onUpdated(@NonNull android.nearby.NearbyDevice);
+  }
+
+  public abstract class ScanFilter {
+    method @IntRange(from=0, to=127) public int getMaxPathLoss();
+    method public int getType();
+  }
+
+  public final class ScanRequest implements android.os.Parcelable {
+    method public int describeContents();
+    method @NonNull public java.util.List<android.nearby.ScanFilter> getScanFilters();
+    method public int getScanMode();
+    method public int getScanType();
+    method @NonNull public android.os.WorkSource getWorkSource();
+    method public boolean isBleEnabled();
+    method public static boolean isValidScanMode(int);
+    method public static boolean isValidScanType(int);
+    method @NonNull public static String scanModeToString(int);
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.nearby.ScanRequest> CREATOR;
+    field public static final int SCAN_MODE_BALANCED = 1; // 0x1
+    field public static final int SCAN_MODE_LOW_LATENCY = 2; // 0x2
+    field public static final int SCAN_MODE_LOW_POWER = 0; // 0x0
+    field public static final int SCAN_MODE_NO_POWER = -1; // 0xffffffff
+    field public static final int SCAN_TYPE_EXPOSURE_NOTIFICATION = 4; // 0x4
+    field public static final int SCAN_TYPE_FAST_PAIR = 1; // 0x1
+    field public static final int SCAN_TYPE_NEARBY_PRESENCE = 3; // 0x3
+    field public static final int SCAN_TYPE_NEARBY_SHARE = 2; // 0x2
+  }
+
+  public static final class ScanRequest.Builder {
+    ctor public ScanRequest.Builder();
+    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 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);
   }
 
 }
@@ -73,7 +526,6 @@
     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 int describeContents();
-    method @NonNull public java.util.Iterator<android.net.NetworkStats.Entry> iterator();
     method @NonNull public android.net.NetworkStats subtract(@NonNull android.net.NetworkStats);
     method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.net.NetworkStats> CREATOR;
@@ -97,23 +549,11 @@
 
   public static class NetworkStats.Entry {
     ctor public NetworkStats.Entry(@Nullable String, int, int, int, int, int, int, long, long, long, long, long);
-    method public int getDefaultNetwork();
-    method public int getMetered();
-    method public long getOperations();
-    method public int getRoaming();
-    method public long getRxBytes();
-    method public long getRxPackets();
-    method public int getSet();
-    method public int getTag();
-    method public long getTxBytes();
-    method public long getTxPackets();
-    method public int getUid();
   }
 
   public class TrafficStats {
     method public static void setThreadStatsTagApp();
     method public static void setThreadStatsTagBackup();
-    method public static void setThreadStatsTagDownload();
     method public static void setThreadStatsTagRestore();
     field public static final int TAG_NETWORK_STACK_IMPERSONATION_RANGE_END = -113; // 0xffffff8f
     field public static final int TAG_NETWORK_STACK_IMPERSONATION_RANGE_START = -128; // 0xffffff80
diff --git a/framework-t/jni/android_net_TrafficStats.cpp b/framework-t/jni/android_net_TrafficStats.cpp
new file mode 100644
index 0000000..f3c58b1
--- /dev/null
+++ b/framework-t/jni/android_net_TrafficStats.cpp
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+#include <android/file_descriptor_jni.h>
+#include <android/multinetwork.h>
+#include <nativehelper/JNIHelp.h>
+
+namespace android {
+
+static jint tagSocketFd(JNIEnv* env, jclass, jobject fileDescriptor, jint tag, jint uid) {
+  int fd = AFileDescriptor_getFd(env, fileDescriptor);
+  if (fd == -1) return -EBADF;
+  return android_tag_socket_with_uid(fd, tag, uid);
+}
+
+static jint untagSocketFd(JNIEnv* env, jclass, jobject fileDescriptor) {
+  int fd = AFileDescriptor_getFd(env, fileDescriptor);
+  if (fd == -1) return -EBADF;
+  return android_untag_socket(fd);
+}
+
+static const JNINativeMethod gMethods[] = {
+    /* name, signature, funcPtr */
+    { "native_tagSocketFd", "(Ljava/io/FileDescriptor;II)I", (void*) tagSocketFd },
+    { "native_untagSocketFd", "(Ljava/io/FileDescriptor;)I", (void*) untagSocketFd },
+};
+
+int register_android_net_TrafficStats(JNIEnv* env) {
+    return jniRegisterNativeMethods(env, "android/net/TrafficStats", gMethods, NELEM(gMethods));
+}
+
+};  // namespace android
+
diff --git a/framework-t/jni/onload.cpp b/framework-t/jni/onload.cpp
new file mode 100644
index 0000000..1fb42c6
--- /dev/null
+++ b/framework-t/jni/onload.cpp
@@ -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.
+ */
+
+#define LOG_TAG "FrameworkConnectivityJNI"
+
+#include <log/log.h>
+#include <nativehelper/JNIHelp.h>
+
+namespace android {
+
+int register_android_net_TrafficStats(JNIEnv* env);
+
+extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
+    JNIEnv *env;
+    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
+        __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "ERROR: GetEnv failed");
+        return JNI_ERR;
+    }
+
+    if (register_android_net_TrafficStats(env) < 0) return JNI_ERR;
+
+    return JNI_VERSION_1_6;
+}
+
+};  // namespace android
+
diff --git a/framework-t/src/android/app/usage/NetworkStats.java b/framework-t/src/android/app/usage/NetworkStats.java
new file mode 100644
index 0000000..74fe4bd
--- /dev/null
+++ b/framework-t/src/android/app/usage/NetworkStats.java
@@ -0,0 +1,744 @@
+/**
+ * Copyright (C) 2015 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.app.usage;
+
+import android.annotation.IntDef;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.net.INetworkStatsService;
+import android.net.INetworkStatsSession;
+import android.net.NetworkStatsHistory;
+import android.net.NetworkTemplate;
+import android.net.TrafficStats;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.net.module.util.CollectionUtils;
+
+import dalvik.system.CloseGuard;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+
+/**
+ * Class providing enumeration over buckets of network usage statistics. {@link NetworkStats} objects
+ * are returned as results to various queries in {@link NetworkStatsManager}.
+ */
+public final class NetworkStats implements AutoCloseable {
+    private final static String TAG = "NetworkStats";
+
+    private final CloseGuard mCloseGuard = CloseGuard.get();
+
+    /**
+     * Start timestamp of stats collected
+     */
+    private final long mStartTimeStamp;
+
+    /**
+     * End timestamp of stats collected
+     */
+    private final long mEndTimeStamp;
+
+    /**
+     * Non-null array indicates the query enumerates over uids.
+     */
+    private int[] mUids;
+
+    /**
+     * Index of the current uid in mUids when doing uid enumeration or a single uid value,
+     * depending on query type.
+     */
+    private int mUidOrUidIndex;
+
+    /**
+     * Tag id in case if was specified in the query.
+     */
+    private int mTag = android.net.NetworkStats.TAG_NONE;
+
+    /**
+     * State in case it was not specified in the query.
+     */
+    private int mState = Bucket.STATE_ALL;
+
+    /**
+     * The session while the query requires it, null if all the stats have been collected or close()
+     * has been called.
+     */
+    private INetworkStatsSession mSession;
+    private NetworkTemplate mTemplate;
+
+    /**
+     * Results of a summary query.
+     */
+    private android.net.NetworkStats mSummary = null;
+
+    /**
+     * Results of detail queries.
+     */
+    private NetworkStatsHistory mHistory = null;
+
+    /**
+     * Where we are in enumerating over the current result.
+     */
+    private int mEnumerationIndex = 0;
+
+    /**
+     * Recycling entry objects to prevent heap fragmentation.
+     */
+    private android.net.NetworkStats.Entry mRecycledSummaryEntry = null;
+    private NetworkStatsHistory.Entry mRecycledHistoryEntry = null;
+
+    /** @hide */
+    NetworkStats(Context context, NetworkTemplate template, int flags, long startTimestamp,
+            long endTimestamp, INetworkStatsService statsService)
+            throws RemoteException, SecurityException {
+        // Open network stats session
+        mSession = statsService.openSessionForUsageStats(flags, context.getOpPackageName());
+        mCloseGuard.open("close");
+        mTemplate = template;
+        mStartTimeStamp = startTimestamp;
+        mEndTimeStamp = endTimestamp;
+    }
+
+    @Override
+    protected void finalize() throws Throwable {
+        try {
+            if (mCloseGuard != null) {
+                mCloseGuard.warnIfOpen();
+            }
+            close();
+        } finally {
+            super.finalize();
+        }
+    }
+
+    // -------------------------BEGINNING OF PUBLIC API-----------------------------------
+
+    /**
+     * Buckets are the smallest elements of a query result. As some dimensions of a result may be
+     * aggregated (e.g. time or state) some values may be equal across all buckets.
+     */
+    public static class Bucket {
+        /** @hide */
+        @IntDef(prefix = { "STATE_" }, value = {
+                STATE_ALL,
+                STATE_DEFAULT,
+                STATE_FOREGROUND
+        })
+        @Retention(RetentionPolicy.SOURCE)
+        public @interface State {}
+
+        /**
+         * Combined usage across all states.
+         */
+        public static final int STATE_ALL = -1;
+
+        /**
+         * Usage not accounted for in any other state.
+         */
+        public static final int STATE_DEFAULT = 0x1;
+
+        /**
+         * Foreground usage.
+         */
+        public static final int STATE_FOREGROUND = 0x2;
+
+        /**
+         * Special UID value for aggregate/unspecified.
+         */
+        public static final int UID_ALL = android.net.NetworkStats.UID_ALL;
+
+        /**
+         * Special UID value for removed apps.
+         */
+        public static final int UID_REMOVED = TrafficStats.UID_REMOVED;
+
+        /**
+         * Special UID value for data usage by tethering.
+         */
+        public static final int UID_TETHERING = TrafficStats.UID_TETHERING;
+
+        /** @hide */
+        @IntDef(prefix = { "METERED_" }, value = {
+                METERED_ALL,
+                METERED_NO,
+                METERED_YES
+        })
+        @Retention(RetentionPolicy.SOURCE)
+        public @interface Metered {}
+
+        /**
+         * Combined usage across all metered states. Covers metered and unmetered usage.
+         */
+        public static final int METERED_ALL = -1;
+
+        /**
+         * Usage that occurs on an unmetered network.
+         */
+        public static final int METERED_NO = 0x1;
+
+        /**
+         * Usage that occurs on a metered network.
+         *
+         * <p>A network is classified as metered when the user is sensitive to heavy data usage on
+         * that connection.
+         */
+        public static final int METERED_YES = 0x2;
+
+        /** @hide */
+        @IntDef(prefix = { "ROAMING_" }, value = {
+                ROAMING_ALL,
+                ROAMING_NO,
+                ROAMING_YES
+        })
+        @Retention(RetentionPolicy.SOURCE)
+        public @interface Roaming {}
+
+        /**
+         * Combined usage across all roaming states. Covers both roaming and non-roaming usage.
+         */
+        public static final int ROAMING_ALL = -1;
+
+        /**
+         * Usage that occurs on a home, non-roaming network.
+         *
+         * <p>Any cellular usage in this bucket was incurred while the device was connected to a
+         * tower owned or operated by the user's wireless carrier, or a tower that the user's
+         * wireless carrier has indicated should be treated as a home network regardless.
+         *
+         * <p>This is also the default value for network types that do not support roaming.
+         */
+        public static final int ROAMING_NO = 0x1;
+
+        /**
+         * Usage that occurs on a roaming network.
+         *
+         * <p>Any cellular usage in this bucket as incurred while the device was roaming on another
+         * carrier's network, for which additional charges may apply.
+         */
+        public static final int ROAMING_YES = 0x2;
+
+        /** @hide */
+        @IntDef(prefix = { "DEFAULT_NETWORK_" }, value = {
+                DEFAULT_NETWORK_ALL,
+                DEFAULT_NETWORK_NO,
+                DEFAULT_NETWORK_YES
+        })
+        @Retention(RetentionPolicy.SOURCE)
+        public @interface DefaultNetworkStatus {}
+
+        /**
+         * Combined usage for this network regardless of default network status.
+         */
+        public static final int DEFAULT_NETWORK_ALL = -1;
+
+        /**
+         * Usage that occurs while this network is not a default network.
+         *
+         * <p>This implies that the app responsible for this usage requested that it occur on a
+         * specific network different from the one(s) the system would have selected for it.
+         */
+        public static final int DEFAULT_NETWORK_NO = 0x1;
+
+        /**
+         * Usage that occurs while this network is a default network.
+         *
+         * <p>This implies that the app either did not select a specific network for this usage,
+         * or it selected a network that the system could have selected for app traffic.
+         */
+        public static final int DEFAULT_NETWORK_YES = 0x2;
+
+        /**
+         * Special TAG value for total data across all tags
+         */
+        public static final int TAG_NONE = android.net.NetworkStats.TAG_NONE;
+
+        private int mUid;
+        private int mTag;
+        private int mState;
+        private int mDefaultNetworkStatus;
+        private int mMetered;
+        private int mRoaming;
+        private long mBeginTimeStamp;
+        private long mEndTimeStamp;
+        private long mRxBytes;
+        private long mRxPackets;
+        private long mTxBytes;
+        private long mTxPackets;
+
+        private static int convertSet(@State int state) {
+            switch (state) {
+                case STATE_ALL: return android.net.NetworkStats.SET_ALL;
+                case STATE_DEFAULT: return android.net.NetworkStats.SET_DEFAULT;
+                case STATE_FOREGROUND: return android.net.NetworkStats.SET_FOREGROUND;
+            }
+            return 0;
+        }
+
+        private static @State int convertState(int networkStatsSet) {
+            switch (networkStatsSet) {
+                case android.net.NetworkStats.SET_ALL : return STATE_ALL;
+                case android.net.NetworkStats.SET_DEFAULT : return STATE_DEFAULT;
+                case android.net.NetworkStats.SET_FOREGROUND : return STATE_FOREGROUND;
+            }
+            return 0;
+        }
+
+        private static int convertUid(int uid) {
+            switch (uid) {
+                case TrafficStats.UID_REMOVED: return UID_REMOVED;
+                case TrafficStats.UID_TETHERING: return UID_TETHERING;
+            }
+            return uid;
+        }
+
+        private static int convertTag(int tag) {
+            switch (tag) {
+                case android.net.NetworkStats.TAG_NONE: return TAG_NONE;
+            }
+            return tag;
+        }
+
+        private static @Metered int convertMetered(int metered) {
+            switch (metered) {
+                case android.net.NetworkStats.METERED_ALL : return METERED_ALL;
+                case android.net.NetworkStats.METERED_NO: return METERED_NO;
+                case android.net.NetworkStats.METERED_YES: return METERED_YES;
+            }
+            return 0;
+        }
+
+        private static @Roaming int convertRoaming(int roaming) {
+            switch (roaming) {
+                case android.net.NetworkStats.ROAMING_ALL : return ROAMING_ALL;
+                case android.net.NetworkStats.ROAMING_NO: return ROAMING_NO;
+                case android.net.NetworkStats.ROAMING_YES: return ROAMING_YES;
+            }
+            return 0;
+        }
+
+        private static @DefaultNetworkStatus int convertDefaultNetworkStatus(
+                int defaultNetworkStatus) {
+            switch (defaultNetworkStatus) {
+                case android.net.NetworkStats.DEFAULT_NETWORK_ALL : return DEFAULT_NETWORK_ALL;
+                case android.net.NetworkStats.DEFAULT_NETWORK_NO: return DEFAULT_NETWORK_NO;
+                case android.net.NetworkStats.DEFAULT_NETWORK_YES: return DEFAULT_NETWORK_YES;
+            }
+            return 0;
+        }
+
+        public Bucket() {
+        }
+
+        /**
+         * Key of the bucket. Usually an app uid or one of the following special values:<p />
+         * <ul>
+         * <li>{@link #UID_REMOVED}</li>
+         * <li>{@link #UID_TETHERING}</li>
+         * <li>{@link android.os.Process#SYSTEM_UID}</li>
+         * </ul>
+         * @return Bucket key.
+         */
+        public int getUid() {
+            return mUid;
+        }
+
+        /**
+         * Tag of the bucket.<p />
+         * @return Bucket tag.
+         */
+        public int getTag() {
+            return mTag;
+        }
+
+        /**
+         * Usage state. One of the following values:<p/>
+         * <ul>
+         * <li>{@link #STATE_ALL}</li>
+         * <li>{@link #STATE_DEFAULT}</li>
+         * <li>{@link #STATE_FOREGROUND}</li>
+         * </ul>
+         * @return Usage state.
+         */
+        public @State int getState() {
+            return mState;
+        }
+
+        /**
+         * Metered state. One of the following values:<p/>
+         * <ul>
+         * <li>{@link #METERED_ALL}</li>
+         * <li>{@link #METERED_NO}</li>
+         * <li>{@link #METERED_YES}</li>
+         * </ul>
+         * <p>A network is classified as metered when the user is sensitive to heavy data usage on
+         * that connection. Apps may warn before using these networks for large downloads. The
+         * metered state can be set by the user within data usage network restrictions.
+         */
+        public @Metered int getMetered() {
+            return mMetered;
+        }
+
+        /**
+         * Roaming state. One of the following values:<p/>
+         * <ul>
+         * <li>{@link #ROAMING_ALL}</li>
+         * <li>{@link #ROAMING_NO}</li>
+         * <li>{@link #ROAMING_YES}</li>
+         * </ul>
+         */
+        public @Roaming int getRoaming() {
+            return mRoaming;
+        }
+
+        /**
+         * Default network status. One of the following values:<p/>
+         * <ul>
+         * <li>{@link #DEFAULT_NETWORK_ALL}</li>
+         * <li>{@link #DEFAULT_NETWORK_NO}</li>
+         * <li>{@link #DEFAULT_NETWORK_YES}</li>
+         * </ul>
+         */
+        public @DefaultNetworkStatus int getDefaultNetworkStatus() {
+            return mDefaultNetworkStatus;
+        }
+
+        /**
+         * Start timestamp of the bucket's time interval. Defined in terms of "Unix time", see
+         * {@link java.lang.System#currentTimeMillis}.
+         * @return Start of interval.
+         */
+        public long getStartTimeStamp() {
+            return mBeginTimeStamp;
+        }
+
+        /**
+         * End timestamp of the bucket's time interval. Defined in terms of "Unix time", see
+         * {@link java.lang.System#currentTimeMillis}.
+         * @return End of interval.
+         */
+        public long getEndTimeStamp() {
+            return mEndTimeStamp;
+        }
+
+        /**
+         * Number of bytes received during the bucket's time interval. Statistics are measured at
+         * the network layer, so they include both TCP and UDP usage.
+         * @return Number of bytes.
+         */
+        public long getRxBytes() {
+            return mRxBytes;
+        }
+
+        /**
+         * Number of bytes transmitted during the bucket's time interval. Statistics are measured at
+         * the network layer, so they include both TCP and UDP usage.
+         * @return Number of bytes.
+         */
+        public long getTxBytes() {
+            return mTxBytes;
+        }
+
+        /**
+         * Number of packets received during the bucket's time interval. Statistics are measured at
+         * the network layer, so they include both TCP and UDP usage.
+         * @return Number of packets.
+         */
+        public long getRxPackets() {
+            return mRxPackets;
+        }
+
+        /**
+         * Number of packets transmitted during the bucket's time interval. Statistics are measured
+         * at the network layer, so they include both TCP and UDP usage.
+         * @return Number of packets.
+         */
+        public long getTxPackets() {
+            return mTxPackets;
+        }
+    }
+
+    /**
+     * Fills the recycled bucket with data of the next bin in the enumeration.
+     * @param bucketOut Bucket to be filled with data. If null, the method does
+     *                  nothing and returning false.
+     * @return true if successfully filled the bucket, false otherwise.
+     */
+    public boolean getNextBucket(@Nullable Bucket bucketOut) {
+        if (mSummary != null) {
+            return getNextSummaryBucket(bucketOut);
+        } else {
+            return getNextHistoryBucket(bucketOut);
+        }
+    }
+
+    /**
+     * Check if it is possible to ask for a next bucket in the enumeration.
+     * @return true if there is at least one more bucket.
+     */
+    public boolean hasNextBucket() {
+        if (mSummary != null) {
+            return mEnumerationIndex < mSummary.size();
+        } else if (mHistory != null) {
+            return mEnumerationIndex < mHistory.size()
+                    || hasNextUid();
+        }
+        return false;
+    }
+
+    /**
+     * Closes the enumeration. Call this method before this object gets out of scope.
+     */
+    @Override
+    public void close() {
+        if (mSession != null) {
+            try {
+                mSession.close();
+            } catch (RemoteException e) {
+                Log.w(TAG, e);
+                // Otherwise, meh
+            }
+        }
+        mSession = null;
+        if (mCloseGuard != null) {
+            mCloseGuard.close();
+        }
+    }
+
+    // -------------------------END OF PUBLIC API-----------------------------------
+
+    /**
+     * Collects device summary results into a Bucket.
+     * @throws RemoteException
+     */
+    Bucket getDeviceSummaryForNetwork() throws RemoteException {
+        mSummary = mSession.getDeviceSummaryForNetwork(mTemplate, mStartTimeStamp, mEndTimeStamp);
+
+        // Setting enumeration index beyond end to avoid accidental enumeration over data that does
+        // not belong to the calling user.
+        mEnumerationIndex = mSummary.size();
+
+        return getSummaryAggregate();
+    }
+
+    /**
+     * Collects summary results and sets summary enumeration mode.
+     * @throws RemoteException
+     */
+    void startSummaryEnumeration() throws RemoteException {
+        mSummary = mSession.getSummaryForAllUid(mTemplate, mStartTimeStamp, mEndTimeStamp,
+                false /* includeTags */);
+        mEnumerationIndex = 0;
+    }
+
+    /**
+     * Collects tagged summary results and sets summary enumeration mode.
+     * @throws RemoteException
+     */
+    void startTaggedSummaryEnumeration() throws RemoteException {
+        mSummary = mSession.getTaggedSummaryForAllUid(mTemplate, mStartTimeStamp, mEndTimeStamp);
+        mEnumerationIndex = 0;
+    }
+
+    /**
+     * Collects history results for uid and resets history enumeration index.
+     */
+    void startHistoryUidEnumeration(int uid, int tag, int state) {
+        mHistory = null;
+        try {
+            mHistory = mSession.getHistoryIntervalForUid(mTemplate, uid,
+                    Bucket.convertSet(state), tag, NetworkStatsHistory.FIELD_ALL,
+                    mStartTimeStamp, mEndTimeStamp);
+            setSingleUidTagState(uid, tag, state);
+        } catch (RemoteException e) {
+            Log.w(TAG, e);
+            // Leaving mHistory null
+        }
+        mEnumerationIndex = 0;
+    }
+
+    /**
+     * Collects history results for network and resets history enumeration index.
+     */
+    void startHistoryDeviceEnumeration() {
+        try {
+            mHistory = mSession.getHistoryIntervalForNetwork(
+                    mTemplate, NetworkStatsHistory.FIELD_ALL, mStartTimeStamp, mEndTimeStamp);
+        } catch (RemoteException e) {
+            Log.w(TAG, e);
+            mHistory = null;
+        }
+        mEnumerationIndex = 0;
+    }
+
+    /**
+     * Starts uid enumeration for current user.
+     * @throws RemoteException
+     */
+    void startUserUidEnumeration() throws RemoteException {
+        // TODO: getRelevantUids should be sensitive to time interval. When that's done,
+        //       the filtering logic below can be removed.
+        int[] uids = mSession.getRelevantUids();
+        // Filtering of uids with empty history.
+        final ArrayList<Integer> filteredUids = new ArrayList<>();
+        for (int uid : uids) {
+            try {
+                NetworkStatsHistory history = mSession.getHistoryIntervalForUid(mTemplate, uid,
+                        android.net.NetworkStats.SET_ALL, android.net.NetworkStats.TAG_NONE,
+                        NetworkStatsHistory.FIELD_ALL, mStartTimeStamp, mEndTimeStamp);
+                if (history != null && history.size() > 0) {
+                    filteredUids.add(uid);
+                }
+            } catch (RemoteException e) {
+                Log.w(TAG, "Error while getting history of uid " + uid, e);
+            }
+        }
+        mUids = CollectionUtils.toIntArray(filteredUids);
+        mUidOrUidIndex = -1;
+        stepHistory();
+    }
+
+    /**
+     * Steps to next uid in enumeration and collects history for that.
+     */
+    private void stepHistory(){
+        if (hasNextUid()) {
+            stepUid();
+            mHistory = null;
+            try {
+                mHistory = mSession.getHistoryIntervalForUid(mTemplate, getUid(),
+                        android.net.NetworkStats.SET_ALL, android.net.NetworkStats.TAG_NONE,
+                        NetworkStatsHistory.FIELD_ALL, mStartTimeStamp, mEndTimeStamp);
+            } catch (RemoteException e) {
+                Log.w(TAG, e);
+                // Leaving mHistory null
+            }
+            mEnumerationIndex = 0;
+        }
+    }
+
+    private void fillBucketFromSummaryEntry(Bucket bucketOut) {
+        bucketOut.mUid = Bucket.convertUid(mRecycledSummaryEntry.uid);
+        bucketOut.mTag = Bucket.convertTag(mRecycledSummaryEntry.tag);
+        bucketOut.mState = Bucket.convertState(mRecycledSummaryEntry.set);
+        bucketOut.mDefaultNetworkStatus = Bucket.convertDefaultNetworkStatus(
+                mRecycledSummaryEntry.defaultNetwork);
+        bucketOut.mMetered = Bucket.convertMetered(mRecycledSummaryEntry.metered);
+        bucketOut.mRoaming = Bucket.convertRoaming(mRecycledSummaryEntry.roaming);
+        bucketOut.mBeginTimeStamp = mStartTimeStamp;
+        bucketOut.mEndTimeStamp = mEndTimeStamp;
+        bucketOut.mRxBytes = mRecycledSummaryEntry.rxBytes;
+        bucketOut.mRxPackets = mRecycledSummaryEntry.rxPackets;
+        bucketOut.mTxBytes = mRecycledSummaryEntry.txBytes;
+        bucketOut.mTxPackets = mRecycledSummaryEntry.txPackets;
+    }
+
+    /**
+     * Getting the next item in summary enumeration.
+     * @param bucketOut Next item will be set here.
+     * @return true if a next item could be set.
+     */
+    private boolean getNextSummaryBucket(@Nullable Bucket bucketOut) {
+        if (bucketOut != null && mEnumerationIndex < mSummary.size()) {
+            mRecycledSummaryEntry = mSummary.getValues(mEnumerationIndex++, mRecycledSummaryEntry);
+            fillBucketFromSummaryEntry(bucketOut);
+            return true;
+        }
+        return false;
+    }
+
+    Bucket getSummaryAggregate() {
+        if (mSummary == null) {
+            return null;
+        }
+        Bucket bucket = new Bucket();
+        if (mRecycledSummaryEntry == null) {
+            mRecycledSummaryEntry = new android.net.NetworkStats.Entry();
+        }
+        mSummary.getTotal(mRecycledSummaryEntry);
+        fillBucketFromSummaryEntry(bucket);
+        return bucket;
+    }
+
+    /**
+     * Getting the next item in a history enumeration.
+     * @param bucketOut Next item will be set here.
+     * @return true if a next item could be set.
+     */
+    private boolean getNextHistoryBucket(@Nullable Bucket bucketOut) {
+        if (bucketOut != null && mHistory != null) {
+            if (mEnumerationIndex < mHistory.size()) {
+                mRecycledHistoryEntry = mHistory.getValues(mEnumerationIndex++,
+                        mRecycledHistoryEntry);
+                bucketOut.mUid = Bucket.convertUid(getUid());
+                bucketOut.mTag = Bucket.convertTag(mTag);
+                bucketOut.mState = mState;
+                bucketOut.mDefaultNetworkStatus = Bucket.DEFAULT_NETWORK_ALL;
+                bucketOut.mMetered = Bucket.METERED_ALL;
+                bucketOut.mRoaming = Bucket.ROAMING_ALL;
+                bucketOut.mBeginTimeStamp = mRecycledHistoryEntry.bucketStart;
+                bucketOut.mEndTimeStamp = mRecycledHistoryEntry.bucketStart +
+                        mRecycledHistoryEntry.bucketDuration;
+                bucketOut.mRxBytes = mRecycledHistoryEntry.rxBytes;
+                bucketOut.mRxPackets = mRecycledHistoryEntry.rxPackets;
+                bucketOut.mTxBytes = mRecycledHistoryEntry.txBytes;
+                bucketOut.mTxPackets = mRecycledHistoryEntry.txPackets;
+                return true;
+            } else if (hasNextUid()) {
+                stepHistory();
+                return getNextHistoryBucket(bucketOut);
+            }
+        }
+        return false;
+    }
+
+    // ------------------ UID LOGIC------------------------
+
+    private boolean isUidEnumeration() {
+        return mUids != null;
+    }
+
+    private boolean hasNextUid() {
+        return isUidEnumeration() && (mUidOrUidIndex + 1) < mUids.length;
+    }
+
+    private int getUid() {
+        // Check if uid enumeration.
+        if (isUidEnumeration()) {
+            if (mUidOrUidIndex < 0 || mUidOrUidIndex >= mUids.length) {
+                throw new IndexOutOfBoundsException(
+                        "Index=" + mUidOrUidIndex + " mUids.length=" + mUids.length);
+            }
+            return mUids[mUidOrUidIndex];
+        }
+        // Single uid mode.
+        return mUidOrUidIndex;
+    }
+
+    private void setSingleUidTagState(int uid, int tag, int state) {
+        mUidOrUidIndex = uid;
+        mTag = tag;
+        mState = state;
+    }
+
+    private void stepUid() {
+        if (mUids != null) {
+            ++mUidOrUidIndex;
+        }
+    }
+}
diff --git a/framework-t/src/android/app/usage/NetworkStatsManager.java b/framework-t/src/android/app/usage/NetworkStatsManager.java
new file mode 100644
index 0000000..f41475b
--- /dev/null
+++ b/framework-t/src/android/app/usage/NetworkStatsManager.java
@@ -0,0 +1,1238 @@
+/**
+ * Copyright (C) 2015 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.app.usage;
+
+import static android.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+
+import android.Manifest;
+import android.annotation.CallbackExecutor;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.annotation.SystemService;
+import android.annotation.WorkerThread;
+import android.app.usage.NetworkStats.Bucket;
+import android.compat.annotation.UnsupportedAppUsage;
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.DataUsageRequest;
+import android.net.INetworkStatsService;
+import android.net.Network;
+import android.net.NetworkStack;
+import android.net.NetworkStateSnapshot;
+import android.net.NetworkTemplate;
+import android.net.UnderlyingNetworkInfo;
+import android.net.netstats.IUsageCallback;
+import android.net.netstats.NetworkStatsDataMigrationUtils;
+import android.net.netstats.provider.INetworkStatsProviderCallback;
+import android.net.netstats.provider.NetworkStatsProvider;
+import android.os.Build;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.NetworkIdentityUtils;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+
+/**
+ * Provides access to network usage history and statistics. Usage data is collected in
+ * discrete bins of time called 'Buckets'. See {@link NetworkStats.Bucket} for details.
+ * <p />
+ * Queries can define a time interval in the form of start and end timestamps (Long.MIN_VALUE and
+ * Long.MAX_VALUE can be used to simulate open ended intervals). By default, apps can only obtain
+ * data about themselves. See the below note for special cases in which apps can obtain data about
+ * other applications.
+ * <h3>
+ * Summary queries
+ * </h3>
+ * {@link #querySummaryForDevice} <p />
+ * {@link #querySummaryForUser} <p />
+ * {@link #querySummary} <p />
+ * These queries aggregate network usage across the whole interval. Therefore there will be only one
+ * bucket for a particular key, state, metered and roaming combination. In case of the user-wide
+ * and device-wide summaries a single bucket containing the totalised network usage is returned.
+ * <h3>
+ * History queries
+ * </h3>
+ * {@link #queryDetailsForUid} <p />
+ * {@link #queryDetails} <p />
+ * These queries do not aggregate over time but do aggregate over state, metered and roaming.
+ * Therefore there can be multiple buckets for a particular key. However, all Buckets will have
+ * {@code state} {@link NetworkStats.Bucket#STATE_ALL},
+ * {@code defaultNetwork} {@link NetworkStats.Bucket#DEFAULT_NETWORK_ALL},
+ * {@code metered } {@link NetworkStats.Bucket#METERED_ALL},
+ * {@code roaming} {@link NetworkStats.Bucket#ROAMING_ALL}.
+ * <p />
+ * <b>NOTE:</b> Calling {@link #querySummaryForDevice} or accessing stats for apps other than the
+ * calling app requires the permission {@link android.Manifest.permission#PACKAGE_USAGE_STATS},
+ * which is a system-level permission and will not be granted to third-party apps. However,
+ * declaring the permission implies intention to use the API and the user of the device can grant
+ * permission through the Settings application.
+ * <p />
+ * Profile owner apps are automatically granted permission to query data on the profile they manage
+ * (that is, for any query except {@link #querySummaryForDevice}). Device owner apps and carrier-
+ * privileged apps likewise get access to usage data for all users on the device.
+ * <p />
+ * In addition to tethering usage, usage by removed users and apps, and usage by the system
+ * is also included in the results for callers with one of these higher levels of access.
+ * <p />
+ * <b>NOTE:</b> Prior to API level {@value android.os.Build.VERSION_CODES#N}, all calls to these APIs required
+ * the above permission, even to access an app's own data usage, and carrier-privileged apps were
+ * not included.
+ */
+@SystemService(Context.NETWORK_STATS_SERVICE)
+public class NetworkStatsManager {
+    private static final String TAG = "NetworkStatsManager";
+    private static final boolean DBG = false;
+
+    /** @hide */
+    public static final int CALLBACK_LIMIT_REACHED = 0;
+    /** @hide */
+    public static final int CALLBACK_RELEASED = 1;
+
+    /**
+     * Minimum data usage threshold for registering usage callbacks.
+     *
+     * Requests registered with a threshold lower than this will only be triggered once this minimum
+     * is reached.
+     * @hide
+     */
+    public static final long MIN_THRESHOLD_BYTES = 2 * 1_048_576L; // 2MiB
+
+    private final Context mContext;
+    private final INetworkStatsService mService;
+
+    /**
+     * @deprecated Use {@link NetworkStatsDataMigrationUtils#PREFIX_XT}
+     * instead.
+     * @hide
+     */
+    @Deprecated
+    public static final String PREFIX_DEV = "dev";
+
+    /** @hide */
+    public static final int FLAG_POLL_ON_OPEN = 1 << 0;
+    /** @hide */
+    public static final int FLAG_POLL_FORCE = 1 << 1;
+    /** @hide */
+    public static final int FLAG_AUGMENT_WITH_SUBSCRIPTION_PLAN = 1 << 2;
+
+    /**
+     * Virtual RAT type to represent 5G NSA (Non Stand Alone) mode, where the primary cell is
+     * still LTE and network allocates a secondary 5G cell so telephony reports RAT = LTE along
+     * with NR state as connected. This is a concept added by NetworkStats on top of the telephony
+     * constants for backward compatibility of metrics so this should not be overlapped with any of
+     * the {@code TelephonyManager.NETWORK_TYPE_*} constants.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int NETWORK_TYPE_5G_NSA = -2;
+
+    private int mFlags;
+
+    /** @hide */
+    @VisibleForTesting
+    public NetworkStatsManager(Context context, INetworkStatsService service) {
+        mContext = context;
+        mService = service;
+        setPollOnOpen(true);
+        setAugmentWithSubscriptionPlan(true);
+    }
+
+    /** @hide */
+    public INetworkStatsService getBinder() {
+        return mService;
+    }
+
+    /**
+     * Set poll on open flag to indicate the poll is needed before service gets statistics
+     * result. This is default enabled. However, for any non-privileged caller, the poll might
+     * be omitted in case of rate limiting.
+     *
+     * @param pollOnOpen true if poll is needed.
+     * @hide
+     */
+    // The system will ignore any non-default values for non-privileged
+    // processes, so processes that don't hold the appropriate permissions
+    // can make no use of this API.
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK})
+    public void setPollOnOpen(boolean pollOnOpen) {
+        if (pollOnOpen) {
+            mFlags |= FLAG_POLL_ON_OPEN;
+        } else {
+            mFlags &= ~FLAG_POLL_ON_OPEN;
+        }
+    }
+
+    /**
+     * Set poll force flag to indicate that calling any subsequent query method will force a stats
+     * poll.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    @SystemApi(client = MODULE_LIBRARIES)
+    public void setPollForce(boolean pollForce) {
+        if (pollForce) {
+            mFlags |= FLAG_POLL_FORCE;
+        } else {
+            mFlags &= ~FLAG_POLL_FORCE;
+        }
+    }
+
+    /** @hide */
+    public void setAugmentWithSubscriptionPlan(boolean augmentWithSubscriptionPlan) {
+        if (augmentWithSubscriptionPlan) {
+            mFlags |= FLAG_AUGMENT_WITH_SUBSCRIPTION_PLAN;
+        } else {
+            mFlags &= ~FLAG_AUGMENT_WITH_SUBSCRIPTION_PLAN;
+        }
+    }
+
+    /**
+     * Query network usage statistics summaries.
+     *
+     * Result is summarised data usage for the whole
+     * device. Result is a single Bucket aggregated over time, state, uid, tag, metered, and
+     * roaming. This means the bucket's start and end timestamp will be the same as the
+     * 'startTime' and 'endTime' arguments. State is going to be
+     * {@link NetworkStats.Bucket#STATE_ALL}, uid {@link NetworkStats.Bucket#UID_ALL},
+     * tag {@link NetworkStats.Bucket#TAG_NONE},
+     * default network {@link NetworkStats.Bucket#DEFAULT_NETWORK_ALL},
+     * metered {@link NetworkStats.Bucket#METERED_ALL},
+     * and roaming {@link NetworkStats.Bucket#ROAMING_ALL}.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param template Template used to match networks. See {@link NetworkTemplate}.
+     * @param startTime Start of period, in milliseconds since the Unix epoch, see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period, in milliseconds since the Unix epoch, see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @return Bucket Summarised data usage.
+     *
+     * @hide
+     */
+    @NonNull
+    @WorkerThread
+    @SystemApi(client = MODULE_LIBRARIES)
+    public Bucket querySummaryForDevice(@NonNull NetworkTemplate template,
+            long startTime, long endTime) {
+        Objects.requireNonNull(template);
+        try {
+            NetworkStats stats =
+                    new NetworkStats(mContext, template, mFlags, startTime, endTime, mService);
+            Bucket bucket = stats.getDeviceSummaryForNetwork();
+            stats.close();
+            return bucket;
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+        return null; // To make the compiler happy.
+    }
+
+    /**
+     * Query network usage statistics summaries. Result is summarised data usage for the whole
+     * device. Result is a single Bucket aggregated over time, state, uid, tag, metered, and
+     * roaming. This means the bucket's start and end timestamp are going to be the same as the
+     * 'startTime' and 'endTime' parameters. State is going to be
+     * {@link NetworkStats.Bucket#STATE_ALL}, uid {@link NetworkStats.Bucket#UID_ALL},
+     * tag {@link NetworkStats.Bucket#TAG_NONE},
+     * default network {@link NetworkStats.Bucket#DEFAULT_NETWORK_ALL},
+     * metered {@link NetworkStats.Bucket#METERED_ALL},
+     * and roaming {@link NetworkStats.Bucket#ROAMING_ALL}.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param networkType As defined in {@link ConnectivityManager}, e.g.
+     *            {@link ConnectivityManager#TYPE_MOBILE}, {@link ConnectivityManager#TYPE_WIFI}
+     *            etc.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when querying for the mobile network type to receive usage
+     *                     for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param startTime Start of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @return Bucket object or null if permissions are insufficient or error happened during
+     *         statistics collection.
+     */
+    @WorkerThread
+    public Bucket querySummaryForDevice(int networkType, @Nullable String subscriberId,
+            long startTime, long endTime) throws SecurityException, RemoteException {
+        NetworkTemplate template;
+        try {
+            template = createTemplate(networkType, subscriberId);
+        } catch (IllegalArgumentException e) {
+            if (DBG) Log.e(TAG, "Cannot create template", e);
+            return null;
+        }
+
+        return querySummaryForDevice(template, startTime, endTime);
+    }
+
+    /**
+     * Query network usage statistics summaries. Result is summarised data usage for all uids
+     * belonging to calling user. Result is a single Bucket aggregated over time, state and uid.
+     * This means the bucket's start and end timestamp are going to be the same as the 'startTime'
+     * and 'endTime' parameters. State is going to be {@link NetworkStats.Bucket#STATE_ALL},
+     * uid {@link NetworkStats.Bucket#UID_ALL}, tag {@link NetworkStats.Bucket#TAG_NONE},
+     * metered {@link NetworkStats.Bucket#METERED_ALL}, and roaming
+     * {@link NetworkStats.Bucket#ROAMING_ALL}.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param networkType As defined in {@link ConnectivityManager}, e.g.
+     *            {@link ConnectivityManager#TYPE_MOBILE}, {@link ConnectivityManager#TYPE_WIFI}
+     *            etc.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when querying for the mobile network type to receive usage
+     *                     for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param startTime Start of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @return Bucket object or null if permissions are insufficient or error happened during
+     *         statistics collection.
+     */
+    @WorkerThread
+    public Bucket querySummaryForUser(int networkType, @Nullable String subscriberId,
+            long startTime, long endTime) throws SecurityException, RemoteException {
+        NetworkTemplate template;
+        try {
+            template = createTemplate(networkType, subscriberId);
+        } catch (IllegalArgumentException e) {
+            if (DBG) Log.e(TAG, "Cannot create template", e);
+            return null;
+        }
+
+        NetworkStats stats;
+        stats = new NetworkStats(mContext, template, mFlags, startTime, endTime, mService);
+        stats.startSummaryEnumeration();
+
+        stats.close();
+        return stats.getSummaryAggregate();
+    }
+
+    /**
+     * Query network usage statistics summaries. Result filtered to include only uids belonging to
+     * calling user. Result is aggregated over time, hence all buckets will have the same start and
+     * end timestamps. Not aggregated over state, uid, default network, metered, or roaming. This
+     * means buckets' start and end timestamps are going to be the same as the 'startTime' and
+     * 'endTime' parameters. State, uid, metered, and roaming are going to vary, and tag is going to
+     * be the same.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param networkType As defined in {@link ConnectivityManager}, e.g.
+     *            {@link ConnectivityManager#TYPE_MOBILE}, {@link ConnectivityManager#TYPE_WIFI}
+     *            etc.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when querying for the mobile network type to receive usage
+     *                     for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param startTime Start of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @return Statistics object or null if permissions are insufficient or error happened during
+     *         statistics collection.
+     */
+    @WorkerThread
+    public NetworkStats querySummary(int networkType, @Nullable String subscriberId, long startTime,
+            long endTime) throws SecurityException, RemoteException {
+        NetworkTemplate template;
+        try {
+            template = createTemplate(networkType, subscriberId);
+        } catch (IllegalArgumentException e) {
+            if (DBG) Log.e(TAG, "Cannot create template", e);
+            return null;
+        }
+
+        return querySummary(template, startTime, endTime);
+    }
+
+    /**
+     * Query network usage statistics summaries.
+     *
+     * The results will only include traffic made by UIDs belonging to the calling user profile.
+     * The results are aggregated over time, so that all buckets will have the same start and
+     * end timestamps as the passed arguments. Not aggregated over state, uid, default network,
+     * metered, or roaming.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param template Template used to match networks. See {@link NetworkTemplate}.
+     * @param startTime Start of period, in milliseconds since the Unix epoch, see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period, in milliseconds since the Unix epoch, see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @return Statistics which is described above.
+     * @hide
+     */
+    @NonNull
+    @SystemApi(client = MODULE_LIBRARIES)
+    @WorkerThread
+    public NetworkStats querySummary(@NonNull NetworkTemplate template, long startTime,
+            long endTime) throws SecurityException {
+        Objects.requireNonNull(template);
+        try {
+            NetworkStats result =
+                    new NetworkStats(mContext, template, mFlags, startTime, endTime, mService);
+            result.startSummaryEnumeration();
+            return result;
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+        return null; // To make the compiler happy.
+    }
+
+    /**
+     * Query tagged network usage statistics summaries.
+     *
+     * The results will only include tagged traffic made by UIDs belonging to the calling user
+     * profile. The results are aggregated over time, so that all buckets will have the same
+     * start and end timestamps as the passed arguments. Not aggregated over state, uid,
+     * default network, metered, or roaming.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param template Template used to match networks. See {@link NetworkTemplate}.
+     * @param startTime Start of period, in milliseconds since the Unix epoch, see
+     *            {@link System#currentTimeMillis}.
+     * @param endTime End of period, in milliseconds since the Unix epoch, see
+     *            {@link System#currentTimeMillis}.
+     * @return Statistics which is described above.
+     * @hide
+     */
+    @NonNull
+    @SystemApi(client = MODULE_LIBRARIES)
+    @WorkerThread
+    public NetworkStats queryTaggedSummary(@NonNull NetworkTemplate template, long startTime,
+            long endTime) throws SecurityException {
+        Objects.requireNonNull(template);
+        try {
+            NetworkStats result =
+                    new NetworkStats(mContext, template, mFlags, startTime, endTime, mService);
+            result.startTaggedSummaryEnumeration();
+            return result;
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+        return null; // To make the compiler happy.
+    }
+
+    /**
+     * Query usage statistics details for networks matching a given {@link NetworkTemplate}.
+     *
+     * Result is not aggregated over time. This means buckets' start and
+     * end timestamps will be between 'startTime' and 'endTime' parameters.
+     * <p>Only includes buckets whose entire time period is included between
+     * startTime and endTime. Doesn't interpolate or return partial buckets.
+     * Since bucket length is in the order of hours, this
+     * method cannot be used to measure data usage on a fine grained time scale.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param template Template used to match networks. See {@link NetworkTemplate}.
+     * @param startTime Start of period, in milliseconds since the Unix epoch, see
+     *                  {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period, in milliseconds since the Unix epoch, see
+     *                {@link java.lang.System#currentTimeMillis}.
+     * @return Statistics which is described above.
+     * @hide
+     */
+    @NonNull
+    @SystemApi(client = MODULE_LIBRARIES)
+    @WorkerThread
+    public NetworkStats queryDetailsForDevice(@NonNull NetworkTemplate template,
+            long startTime, long endTime) {
+        Objects.requireNonNull(template);
+        try {
+            final NetworkStats result =
+                    new NetworkStats(mContext, template, mFlags, startTime, endTime, mService);
+            result.startHistoryDeviceEnumeration();
+            return result;
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+
+        return null; // To make the compiler happy.
+    }
+
+    /**
+     * Query network usage statistics details for a given uid.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @see #queryDetailsForUidTagState(int, String, long, long, int, int, int)
+     */
+    @NonNull
+    @WorkerThread
+    public NetworkStats queryDetailsForUid(int networkType, @Nullable String subscriberId,
+            long startTime, long endTime, int uid) throws SecurityException {
+        return queryDetailsForUidTagState(networkType, subscriberId, startTime, endTime, uid,
+            NetworkStats.Bucket.TAG_NONE, NetworkStats.Bucket.STATE_ALL);
+    }
+
+    /** @hide */
+    @NonNull
+    public NetworkStats queryDetailsForUid(@NonNull NetworkTemplate template,
+            long startTime, long endTime, int uid) throws SecurityException {
+        return queryDetailsForUidTagState(template, startTime, endTime, uid,
+                NetworkStats.Bucket.TAG_NONE, NetworkStats.Bucket.STATE_ALL);
+    }
+
+    /**
+     * Query network usage statistics details for a given uid and tag.
+     *
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     * Only usable for uids belonging to calling user. Result is not aggregated over time.
+     * This means buckets' start and end timestamps are going to be between 'startTime' and
+     * 'endTime' parameters. The uid is going to be the same as the 'uid' parameter, the tag
+     * the same as the 'tag' parameter, and the state the same as the 'state' parameter.
+     * defaultNetwork is going to be {@link NetworkStats.Bucket#DEFAULT_NETWORK_ALL},
+     * metered is going to be {@link NetworkStats.Bucket#METERED_ALL}, and
+     * roaming is going to be {@link NetworkStats.Bucket#ROAMING_ALL}.
+     * <p>Only includes buckets that atomically occur in the inclusive time range. Doesn't
+     * interpolate across partial buckets. Since bucket length is in the order of hours, this
+     * method cannot be used to measure data usage on a fine grained time scale.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param networkType As defined in {@link ConnectivityManager}, e.g.
+     *            {@link ConnectivityManager#TYPE_MOBILE}, {@link ConnectivityManager#TYPE_WIFI}
+     *            etc.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when querying for the mobile network type to receive usage
+     *                     for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param startTime Start of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param uid UID of app
+     * @param tag TAG of interest. Use {@link NetworkStats.Bucket#TAG_NONE} for aggregated data
+     *            across all the tags.
+     * @return Statistics which is described above.
+     * @throws SecurityException if permissions are insufficient to read network statistics.
+     */
+    @NonNull
+    @WorkerThread
+    public NetworkStats queryDetailsForUidTag(int networkType, @Nullable String subscriberId,
+            long startTime, long endTime, int uid, int tag) throws SecurityException {
+        return queryDetailsForUidTagState(networkType, subscriberId, startTime, endTime, uid,
+            tag, NetworkStats.Bucket.STATE_ALL);
+    }
+
+    /**
+     * Query network usage statistics details for a given uid, tag, and state.
+     *
+     * Only usable for uids belonging to calling user. Result is not aggregated over time.
+     * This means buckets' start and end timestamps are going to be between 'startTime' and
+     * 'endTime' parameters. The uid is going to be the same as the 'uid' parameter, the tag
+     * the same as the 'tag' parameter, and the state the same as the 'state' parameter.
+     * defaultNetwork is going to be {@link NetworkStats.Bucket#DEFAULT_NETWORK_ALL},
+     * metered is going to be {@link NetworkStats.Bucket#METERED_ALL}, and
+     * roaming is going to be {@link NetworkStats.Bucket#ROAMING_ALL}.
+     * <p>Only includes buckets that atomically occur in the inclusive time range. Doesn't
+     * interpolate across partial buckets. Since bucket length is in the order of hours, this
+     * method cannot be used to measure data usage on a fine grained time scale.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param networkType As defined in {@link ConnectivityManager}, e.g.
+     *            {@link ConnectivityManager#TYPE_MOBILE}, {@link ConnectivityManager#TYPE_WIFI}
+     *            etc.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when querying for the mobile network type to receive usage
+     *                     for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param startTime Start of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param uid UID of app
+     * @param tag TAG of interest. Use {@link NetworkStats.Bucket#TAG_NONE} for aggregated data
+     *            across all the tags.
+     * @param state state of interest. Use {@link NetworkStats.Bucket#STATE_ALL} to aggregate
+     *            traffic from all states.
+     * @return Statistics which is described above.
+     * @throws SecurityException if permissions are insufficient to read network statistics.
+     */
+    @NonNull
+    @WorkerThread
+    public NetworkStats queryDetailsForUidTagState(int networkType, @Nullable String subscriberId,
+            long startTime, long endTime, int uid, int tag, int state) throws SecurityException {
+        NetworkTemplate template;
+        template = createTemplate(networkType, subscriberId);
+
+        return queryDetailsForUidTagState(template, startTime, endTime, uid, tag, state);
+    }
+
+    /**
+     * Query network usage statistics details for a given template, uid, tag, and state.
+     *
+     * Only usable for uids belonging to calling user. Result is not aggregated over time.
+     * This means buckets' start and end timestamps are going to be between 'startTime' and
+     * 'endTime' parameters. The uid is going to be the same as the 'uid' parameter, the tag
+     * the same as the 'tag' parameter, and the state the same as the 'state' parameter.
+     * defaultNetwork is going to be {@link NetworkStats.Bucket#DEFAULT_NETWORK_ALL},
+     * metered is going to be {@link NetworkStats.Bucket#METERED_ALL}, and
+     * roaming is going to be {@link NetworkStats.Bucket#ROAMING_ALL}.
+     * <p>Only includes buckets that atomically occur in the inclusive time range. Doesn't
+     * interpolate across partial buckets. Since bucket length is in the order of hours, this
+     * method cannot be used to measure data usage on a fine grained time scale.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param template Template used to match networks. See {@link NetworkTemplate}.
+     * @param startTime Start of period, in milliseconds since the Unix epoch, see
+     *                  {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period, in milliseconds since the Unix epoch, see
+     *                {@link java.lang.System#currentTimeMillis}.
+     * @param uid UID of app
+     * @param tag TAG of interest. Use {@link NetworkStats.Bucket#TAG_NONE} for aggregated data
+     *            across all the tags.
+     * @param state state of interest. Use {@link NetworkStats.Bucket#STATE_ALL} to aggregate
+     *            traffic from all states.
+     * @return Statistics which is described above.
+     * @hide
+     */
+    @NonNull
+    @SystemApi(client = MODULE_LIBRARIES)
+    @WorkerThread
+    public NetworkStats queryDetailsForUidTagState(@NonNull NetworkTemplate template,
+            long startTime, long endTime, int uid, int tag, int state) throws SecurityException {
+        Objects.requireNonNull(template);
+        try {
+            final NetworkStats result = new NetworkStats(
+                    mContext, template, mFlags, startTime, endTime, mService);
+            result.startHistoryUidEnumeration(uid, tag, state);
+            return result;
+        } catch (RemoteException e) {
+            Log.e(TAG, "Error while querying stats for uid=" + uid + " tag=" + tag
+                    + " state=" + state, e);
+            e.rethrowFromSystemServer();
+        }
+
+        return null; // To make the compiler happy.
+    }
+
+    /**
+     * Query network usage statistics details. Result filtered to include only uids belonging to
+     * calling user. Result is aggregated over state but not aggregated over time, uid, tag,
+     * metered, nor roaming. This means buckets' start and end timestamps are going to be between
+     * 'startTime' and 'endTime' parameters. State is going to be
+     * {@link NetworkStats.Bucket#STATE_ALL}, uid will vary,
+     * tag {@link NetworkStats.Bucket#TAG_NONE},
+     * default network is going to be {@link NetworkStats.Bucket#DEFAULT_NETWORK_ALL},
+     * metered is going to be {@link NetworkStats.Bucket#METERED_ALL},
+     * and roaming is going to be {@link NetworkStats.Bucket#ROAMING_ALL}.
+     * <p>Only includes buckets that atomically occur in the inclusive time range. Doesn't
+     * interpolate across partial buckets. Since bucket length is in the order of hours, this
+     * method cannot be used to measure data usage on a fine grained time scale.
+     * This may take a long time, and apps should avoid calling this on their main thread.
+     *
+     * @param networkType As defined in {@link ConnectivityManager}, e.g.
+     *            {@link ConnectivityManager#TYPE_MOBILE}, {@link ConnectivityManager#TYPE_WIFI}
+     *            etc.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when querying for the mobile network type to receive usage
+     *                     for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param startTime Start of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @param endTime End of period. Defined in terms of "Unix time", see
+     *            {@link java.lang.System#currentTimeMillis}.
+     * @return Statistics object or null if permissions are insufficient or error happened during
+     *         statistics collection.
+     */
+    @WorkerThread
+    public NetworkStats queryDetails(int networkType, @Nullable String subscriberId, long startTime,
+            long endTime) throws SecurityException, RemoteException {
+        NetworkTemplate template;
+        try {
+            template = createTemplate(networkType, subscriberId);
+        } catch (IllegalArgumentException e) {
+            if (DBG) Log.e(TAG, "Cannot create template", e);
+            return null;
+        }
+
+        NetworkStats result;
+        result = new NetworkStats(mContext, template, mFlags, startTime, endTime, mService);
+        result.startUserUidEnumeration();
+        return result;
+    }
+
+    /**
+     * Query realtime mobile network usage statistics.
+     *
+     * Return a snapshot of current UID network statistics, as it applies
+     * to the mobile radios of the device. The snapshot will include any
+     * tethering traffic, video calling data usage and count of
+     * network operations set by {@link TrafficStats#incrementOperationCount}
+     * made over a mobile radio.
+     * The snapshot will not include any statistics that cannot be seen by
+     * the kernel, e.g. statistics reported by {@link NetworkStatsProvider}s.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK})
+    @NonNull public android.net.NetworkStats getMobileUidStats() {
+        try {
+            return mService.getUidStatsForTransport(TRANSPORT_CELLULAR);
+        } catch (RemoteException e) {
+            if (DBG) Log.d(TAG, "Remote exception when get Mobile uid stats");
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Query realtime Wi-Fi network usage statistics.
+     *
+     * Return a snapshot of current UID network statistics, as it applies
+     * to the Wi-Fi radios of the device. The snapshot will include any
+     * tethering traffic, video calling data usage and count of
+     * network operations set by {@link TrafficStats#incrementOperationCount}
+     * made over a Wi-Fi radio.
+     * The snapshot will not include any statistics that cannot be seen by
+     * the kernel, e.g. statistics reported by {@link NetworkStatsProvider}s.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK})
+    @NonNull public android.net.NetworkStats getWifiUidStats() {
+        try {
+            return mService.getUidStatsForTransport(TRANSPORT_WIFI);
+        } catch (RemoteException e) {
+            if (DBG) Log.d(TAG, "Remote exception when get WiFi uid stats");
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Registers to receive notifications about data usage on specified networks.
+     *
+     * <p>The callbacks will continue to be called as long as the process is alive or
+     * {@link #unregisterUsageCallback} is called.
+     *
+     * @param template Template used to match networks. See {@link NetworkTemplate}.
+     * @param thresholdBytes Threshold in bytes to be notified on. Provided values lower than 2MiB
+     *                       will be clamped for callers except callers with the NETWORK_STACK
+     *                       permission.
+     * @param executor The executor on which callback will be invoked. The provided {@link Executor}
+     *                 must run callback sequentially, otherwise the order of callbacks cannot be
+     *                 guaranteed.
+     * @param callback The {@link UsageCallback} that the system will call when data usage
+     *                 has exceeded the specified threshold.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK}, conditional = true)
+    public void registerUsageCallback(@NonNull NetworkTemplate template, long thresholdBytes,
+            @NonNull @CallbackExecutor Executor executor, @NonNull UsageCallback callback) {
+        Objects.requireNonNull(template, "NetworkTemplate cannot be null");
+        Objects.requireNonNull(callback, "UsageCallback cannot be null");
+        Objects.requireNonNull(executor, "Executor cannot be null");
+
+        final DataUsageRequest request = new DataUsageRequest(DataUsageRequest.REQUEST_ID_UNSET,
+                template, thresholdBytes);
+        try {
+            final UsageCallbackWrapper callbackWrapper =
+                    new UsageCallbackWrapper(executor, callback);
+            callback.request = mService.registerUsageCallback(
+                    mContext.getOpPackageName(), request, callbackWrapper);
+            if (DBG) Log.d(TAG, "registerUsageCallback returned " + callback.request);
+
+            if (callback.request == null) {
+                Log.e(TAG, "Request from callback is null; should not happen");
+            }
+        } catch (RemoteException e) {
+            if (DBG) Log.d(TAG, "Remote exception when registering callback");
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Registers to receive notifications about data usage on specified networks.
+     *
+     * <p>The callbacks will continue to be called as long as the process is live or
+     * {@link #unregisterUsageCallback} is called.
+     *
+     * @param networkType Type of network to monitor. Either
+    {@link ConnectivityManager#TYPE_MOBILE} or {@link ConnectivityManager#TYPE_WIFI}.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when registering for the mobile network type to receive
+     *                     notifications for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param thresholdBytes Threshold in bytes to be notified on.
+     * @param callback The {@link UsageCallback} that the system will call when data usage
+     *            has exceeded the specified threshold.
+     */
+    public void registerUsageCallback(int networkType, @Nullable String subscriberId,
+            long thresholdBytes, @NonNull UsageCallback callback) {
+        registerUsageCallback(networkType, subscriberId, thresholdBytes, callback,
+                null /* handler */);
+    }
+
+    /**
+     * Registers to receive notifications about data usage on specified networks.
+     *
+     * <p>The callbacks will continue to be called as long as the process is live or
+     * {@link #unregisterUsageCallback} is called.
+     *
+     * @param networkType Type of network to monitor. Either
+                  {@link ConnectivityManager#TYPE_MOBILE} or {@link ConnectivityManager#TYPE_WIFI}.
+     * @param subscriberId If applicable, the subscriber id of the network interface.
+     *                     <p>Starting with API level 29, the {@code subscriberId} is guarded by
+     *                     additional restrictions. Calling apps that do not meet the new
+     *                     requirements to access the {@code subscriberId} can provide a {@code
+     *                     null} value when registering for the mobile network type to receive
+     *                     notifications for all mobile networks. For additional details see {@link
+     *                     TelephonyManager#getSubscriberId()}.
+     *                     <p>Starting with API level 31, calling apps can provide a
+     *                     {@code subscriberId} with wifi network type to receive usage for
+     *                     wifi networks which is under the given subscription if applicable.
+     *                     Otherwise, pass {@code null} when querying all wifi networks.
+     * @param thresholdBytes Threshold in bytes to be notified on.
+     * @param callback The {@link UsageCallback} that the system will call when data usage
+     *            has exceeded the specified threshold.
+     * @param handler to dispatch callback events through, otherwise if {@code null} it uses
+     *            the calling thread.
+     */
+    public void registerUsageCallback(int networkType, @Nullable String subscriberId,
+            long thresholdBytes, @NonNull UsageCallback callback, @Nullable Handler handler) {
+        NetworkTemplate template = createTemplate(networkType, subscriberId);
+        if (DBG) {
+            Log.d(TAG, "registerUsageCallback called with: {"
+                    + " networkType=" + networkType
+                    + " subscriberId=" + subscriberId
+                    + " thresholdBytes=" + thresholdBytes
+                    + " }");
+        }
+
+        final Executor executor = handler == null ? r -> r.run() : r -> handler.post(r);
+
+        registerUsageCallback(template, thresholdBytes, executor, callback);
+    }
+
+    /**
+     * Unregisters callbacks on data usage.
+     *
+     * @param callback The {@link UsageCallback} used when registering.
+     */
+    public void unregisterUsageCallback(@NonNull UsageCallback callback) {
+        if (callback == null || callback.request == null
+                || callback.request.requestId == DataUsageRequest.REQUEST_ID_UNSET) {
+            throw new IllegalArgumentException("Invalid UsageCallback");
+        }
+        try {
+            mService.unregisterUsageRequest(callback.request);
+        } catch (RemoteException e) {
+            if (DBG) Log.d(TAG, "Remote exception when unregistering callback");
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Base class for usage callbacks. Should be extended by applications wanting notifications.
+     */
+    public static abstract class UsageCallback {
+        /**
+         * Called when data usage has reached the given threshold.
+         *
+         * Called by {@code NetworkStatsService} when the registered threshold is reached.
+         * If a caller implements {@link #onThresholdReached(NetworkTemplate)}, the system
+         * will not call {@link #onThresholdReached(int, String)}.
+         *
+         * @param template The {@link NetworkTemplate} that associated with this callback.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public void onThresholdReached(@NonNull NetworkTemplate template) {
+            // Backward compatibility for those who didn't override this function.
+            final int networkType = networkTypeForTemplate(template);
+            if (networkType != ConnectivityManager.TYPE_NONE) {
+                final String subscriberId = template.getSubscriberIds().isEmpty() ? null
+                        : template.getSubscriberIds().iterator().next();
+                onThresholdReached(networkType, subscriberId);
+            }
+        }
+
+        /**
+         * Called when data usage has reached the given threshold.
+         */
+        public abstract void onThresholdReached(int networkType, @Nullable String subscriberId);
+
+        /**
+         * @hide used for internal bookkeeping
+         */
+        private DataUsageRequest request;
+
+        /**
+         * Get network type from a template if feasible.
+         *
+         * @param template the target {@link NetworkTemplate}.
+         * @return legacy network type, only supports for the types which is already supported in
+         *         {@link #registerUsageCallback(int, String, long, UsageCallback, Handler)}.
+         *         {@link ConnectivityManager#TYPE_NONE} for other types.
+         */
+        private static int networkTypeForTemplate(@NonNull NetworkTemplate template) {
+            switch (template.getMatchRule()) {
+                case NetworkTemplate.MATCH_MOBILE:
+                    return ConnectivityManager.TYPE_MOBILE;
+                case NetworkTemplate.MATCH_WIFI:
+                    return ConnectivityManager.TYPE_WIFI;
+                default:
+                    return ConnectivityManager.TYPE_NONE;
+            }
+        }
+    }
+
+    /**
+     * Registers a custom provider of {@link android.net.NetworkStats} to provide network statistics
+     * to the system. To unregister, invoke {@link #unregisterNetworkStatsProvider}.
+     * Note that no de-duplication of statistics between providers is performed, so each provider
+     * must only report network traffic that is not being reported by any other provider. Also note
+     * that the provider cannot be re-registered after unregistering.
+     *
+     * @param tag a human readable identifier of the custom network stats provider. This is only
+     *            used for debugging.
+     * @param provider the subclass of {@link NetworkStatsProvider} that needs to be
+     *                 registered to the system.
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(anyOf = {
+            android.Manifest.permission.NETWORK_STATS_PROVIDER,
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK})
+    public void registerNetworkStatsProvider(
+            @NonNull String tag,
+            @NonNull NetworkStatsProvider provider) {
+        try {
+            if (provider.getProviderCallbackBinder() != null) {
+                throw new IllegalArgumentException("provider is already registered");
+            }
+            final INetworkStatsProviderCallback cbBinder =
+                    mService.registerNetworkStatsProvider(tag, provider.getProviderBinder());
+            provider.setProviderCallbackBinder(cbBinder);
+        } catch (RemoteException e) {
+            e.rethrowAsRuntimeException();
+        }
+    }
+
+    /**
+     * Unregisters an instance of {@link NetworkStatsProvider}.
+     *
+     * @param provider the subclass of {@link NetworkStatsProvider} that needs to be
+     *                 unregistered to the system.
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(anyOf = {
+            android.Manifest.permission.NETWORK_STATS_PROVIDER,
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK})
+    public void unregisterNetworkStatsProvider(@NonNull NetworkStatsProvider provider) {
+        try {
+            provider.getProviderCallbackBinderOrThrow().unregister();
+        } catch (RemoteException e) {
+            e.rethrowAsRuntimeException();
+        }
+    }
+
+    private static NetworkTemplate createTemplate(int networkType, @Nullable String subscriberId) {
+        final NetworkTemplate template;
+        switch (networkType) {
+            case ConnectivityManager.TYPE_MOBILE:
+                template = subscriberId == null
+                        ? NetworkTemplate.buildTemplateMobileWildcard()
+                        : NetworkTemplate.buildTemplateMobileAll(subscriberId);
+                break;
+            case ConnectivityManager.TYPE_WIFI:
+                template = TextUtils.isEmpty(subscriberId)
+                        ? NetworkTemplate.buildTemplateWifiWildcard()
+                        : NetworkTemplate.buildTemplateWifi(NetworkTemplate.WIFI_NETWORKID_ALL,
+                                subscriberId);
+                break;
+            default:
+                throw new IllegalArgumentException("Cannot create template for network type "
+                        + networkType + ", subscriberId '"
+                        + NetworkIdentityUtils.scrubSubscriberId(subscriberId) + "'.");
+        }
+        return template;
+    }
+
+    /**
+     * Notify {@code NetworkStatsService} about network status changed.
+     *
+     * Notifies NetworkStatsService of network state changes for data usage accounting purposes.
+     *
+     * To avoid races that attribute data usage to wrong network, such as new network with
+     * the same interface after SIM hot-swap, this function will not return until
+     * {@code NetworkStatsService} finishes its work of retrieving traffic statistics from
+     * all data sources.
+     *
+     * @param defaultNetworks the list of all networks that could be used by network traffic that
+     *                        does not explicitly select a network.
+     * @param networkStateSnapshots a list of {@link NetworkStateSnapshot}s, one for
+     *                              each network that is currently connected.
+     * @param activeIface the active (i.e., connected) default network interface for the calling
+     *                    uid. Used to determine on which network future calls to
+     *                    {@link android.net.TrafficStats#incrementOperationCount} applies to.
+     * @param underlyingNetworkInfos the list of underlying network information for all
+     *                               currently-connected VPNs.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK})
+    public void notifyNetworkStatus(
+            @NonNull List<Network> defaultNetworks,
+            @NonNull List<NetworkStateSnapshot> networkStateSnapshots,
+            @Nullable String activeIface,
+            @NonNull List<UnderlyingNetworkInfo> underlyingNetworkInfos) {
+        try {
+            Objects.requireNonNull(defaultNetworks);
+            Objects.requireNonNull(networkStateSnapshots);
+            Objects.requireNonNull(underlyingNetworkInfos);
+            mService.notifyNetworkStatus(defaultNetworks.toArray(new Network[0]),
+                    networkStateSnapshots.toArray(new NetworkStateSnapshot[0]), activeIface,
+                    underlyingNetworkInfos.toArray(new UnderlyingNetworkInfo[0]));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    private static class UsageCallbackWrapper extends IUsageCallback.Stub {
+        // Null if unregistered.
+        private volatile UsageCallback mCallback;
+
+        private final Executor mExecutor;
+
+        UsageCallbackWrapper(@NonNull Executor executor, @NonNull UsageCallback callback) {
+            mCallback = callback;
+            mExecutor = executor;
+        }
+
+        @Override
+        public void onThresholdReached(DataUsageRequest request) {
+            // Copy it to a local variable in case mCallback changed inside the if condition.
+            final UsageCallback callback = mCallback;
+            if (callback != null) {
+                mExecutor.execute(() -> callback.onThresholdReached(request.template));
+            } else {
+                Log.e(TAG, "onThresholdReached with released callback for " + request);
+            }
+        }
+
+        @Override
+        public void onCallbackReleased(DataUsageRequest request) {
+            if (DBG) Log.d(TAG, "callback released for " + request);
+            mCallback = null;
+        }
+    }
+
+    /**
+     * Mark given UID as being in foreground for stats purposes.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK})
+    public void noteUidForeground(int uid, boolean uidForeground) {
+        try {
+            mService.noteUidForeground(uid, uidForeground);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Set default value of global alert bytes, the value will be clamped to [128kB, 2MB].
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            Manifest.permission.NETWORK_STACK})
+    public void setDefaultGlobalAlert(long alertBytes) {
+        try {
+            // TODO: Sync internal naming with the API surface.
+            mService.advisePersistThreshold(alertBytes);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Force update of statistics.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK})
+    public void forceUpdate() {
+        try {
+            mService.forceUpdate();
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Set the warning and limit to all registered custom network stats providers.
+     * Note that invocation of any interface will be sent to all providers.
+     *
+     * Asynchronicity notes : because traffic may be happening on the device at the same time, it
+     * doesn't make sense to wait for the warning and limit to be set – a caller still wouldn't
+     * know when exactly it was effective. All that can matter is that it's done quickly. Also,
+     * this method can't fail, so there is no status to return. All providers will see the new
+     * values soon.
+     * As such, this method returns immediately and sends the warning and limit to all providers
+     * as soon as possible through a one-way binder call.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK})
+    public void setStatsProviderWarningAndLimitAsync(@NonNull String iface, long warning,
+            long limit) {
+        try {
+            mService.setStatsProviderWarningAndLimitAsync(iface, warning, limit);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Get a RAT type representative of a group of RAT types for network statistics.
+     *
+     * Collapse the given Radio Access Technology (RAT) type into a bucket that
+     * is representative of the original RAT type for network statistics. The
+     * mapping mostly corresponds to {@code TelephonyManager#NETWORK_CLASS_BIT_MASK_*}
+     * but with adaptations specific to the virtual types introduced by
+     * networks stats.
+     *
+     * @param ratType An integer defined in {@code TelephonyManager#NETWORK_TYPE_*}.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static int getCollapsedRatType(int ratType) {
+        switch (ratType) {
+            case TelephonyManager.NETWORK_TYPE_GPRS:
+            case TelephonyManager.NETWORK_TYPE_GSM:
+            case TelephonyManager.NETWORK_TYPE_EDGE:
+            case TelephonyManager.NETWORK_TYPE_IDEN:
+            case TelephonyManager.NETWORK_TYPE_CDMA:
+            case TelephonyManager.NETWORK_TYPE_1xRTT:
+                return TelephonyManager.NETWORK_TYPE_GSM;
+            case TelephonyManager.NETWORK_TYPE_EVDO_0:
+            case TelephonyManager.NETWORK_TYPE_EVDO_A:
+            case TelephonyManager.NETWORK_TYPE_EVDO_B:
+            case TelephonyManager.NETWORK_TYPE_EHRPD:
+            case TelephonyManager.NETWORK_TYPE_UMTS:
+            case TelephonyManager.NETWORK_TYPE_HSDPA:
+            case TelephonyManager.NETWORK_TYPE_HSUPA:
+            case TelephonyManager.NETWORK_TYPE_HSPA:
+            case TelephonyManager.NETWORK_TYPE_HSPAP:
+            case TelephonyManager.NETWORK_TYPE_TD_SCDMA:
+                return TelephonyManager.NETWORK_TYPE_UMTS;
+            case TelephonyManager.NETWORK_TYPE_LTE:
+            case TelephonyManager.NETWORK_TYPE_IWLAN:
+                return TelephonyManager.NETWORK_TYPE_LTE;
+            case TelephonyManager.NETWORK_TYPE_NR:
+                return TelephonyManager.NETWORK_TYPE_NR;
+            // Virtual RAT type for 5G NSA mode, see
+            // {@link NetworkStatsManager#NETWORK_TYPE_5G_NSA}.
+            case NetworkStatsManager.NETWORK_TYPE_5G_NSA:
+                return NetworkStatsManager.NETWORK_TYPE_5G_NSA;
+            default:
+                return TelephonyManager.NETWORK_TYPE_UNKNOWN;
+        }
+    }
+}
diff --git a/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java b/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
new file mode 100644
index 0000000..61b34d0
--- /dev/null
+++ b/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
@@ -0,0 +1,82 @@
+/*
+ * 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 android.net;
+
+import android.annotation.SystemApi;
+import android.app.SystemServiceRegistry;
+import android.app.usage.NetworkStatsManager;
+import android.content.Context;
+import android.net.nsd.INsdManager;
+import android.net.nsd.NsdManager;
+
+/**
+ * Class for performing registration for Connectivity services which are exposed via updatable APIs
+ * since Android T.
+ *
+ * @hide
+ */
+@SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+public final class ConnectivityFrameworkInitializerTiramisu {
+    private ConnectivityFrameworkInitializerTiramisu() {}
+
+    /**
+     * Called by {@link SystemServiceRegistry}'s static initializer and registers NetworkStats, nsd,
+     * ipsec and ethernet services to {@link Context}, so that {@link Context#getSystemService} can
+     * return them.
+     *
+     * @throws IllegalStateException if this is called anywhere besides
+     * {@link SystemServiceRegistry}.
+     */
+    public static void registerServiceWrappers() {
+        SystemServiceRegistry.registerContextAwareService(
+                Context.NSD_SERVICE,
+                NsdManager.class,
+                (context, serviceBinder) -> {
+                    INsdManager service = INsdManager.Stub.asInterface(serviceBinder);
+                    return new NsdManager(context, service);
+                }
+        );
+
+        SystemServiceRegistry.registerContextAwareService(
+                Context.IPSEC_SERVICE,
+                IpSecManager.class,
+                (context, serviceBinder) -> {
+                    IIpSecService service = IIpSecService.Stub.asInterface(serviceBinder);
+                    return new IpSecManager(context, service);
+                }
+        );
+
+        SystemServiceRegistry.registerContextAwareService(
+                Context.NETWORK_STATS_SERVICE,
+                NetworkStatsManager.class,
+                (context, serviceBinder) -> {
+                    INetworkStatsService service =
+                            INetworkStatsService.Stub.asInterface(serviceBinder);
+                    return new NetworkStatsManager(context, service);
+                }
+        );
+
+        SystemServiceRegistry.registerContextAwareService(
+                Context.ETHERNET_SERVICE,
+                EthernetManager.class,
+                (context, serviceBinder) -> {
+                    IEthernetManager service = IEthernetManager.Stub.asInterface(serviceBinder);
+                    return new EthernetManager(context, service);
+                }
+        );
+    }
+}
diff --git a/framework-t/src/android/net/DataUsageRequest.aidl b/framework-t/src/android/net/DataUsageRequest.aidl
new file mode 100644
index 0000000..d1937c7
--- /dev/null
+++ b/framework-t/src/android/net/DataUsageRequest.aidl
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2016, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+parcelable DataUsageRequest;
diff --git a/framework-t/src/android/net/DataUsageRequest.java b/framework-t/src/android/net/DataUsageRequest.java
new file mode 100644
index 0000000..f0ff465
--- /dev/null
+++ b/framework-t/src/android/net/DataUsageRequest.java
@@ -0,0 +1,112 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package android.net;
+
+import android.annotation.Nullable;
+import android.net.NetworkTemplate;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.Objects;
+
+/**
+ * Defines a request to register a callbacks. Used to be notified on data usage via
+ * {@link android.app.usage.NetworkStatsManager#registerDataUsageCallback}.
+ * If no {@code uid}s are set, callbacks are restricted to device-owners,
+ * carrier-privileged apps, or system apps.
+ *
+ * @hide
+ */
+public final class DataUsageRequest implements Parcelable {
+
+    public static final String PARCELABLE_KEY = "DataUsageRequest";
+    public static final int REQUEST_ID_UNSET = 0;
+
+    /**
+     * Identifies the request.  {@link DataUsageRequest}s should only be constructed by
+     * the Framework and it is used internally to identify the request.
+     */
+    public final int requestId;
+
+    /**
+     * {@link NetworkTemplate} describing the network to monitor.
+     */
+    public final NetworkTemplate template;
+
+    /**
+     * Threshold in bytes to be notified on.
+     */
+    public final long thresholdInBytes;
+
+    public DataUsageRequest(int requestId, NetworkTemplate template, long thresholdInBytes) {
+        this.requestId = requestId;
+        this.template = template;
+        this.thresholdInBytes = thresholdInBytes;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(requestId);
+        dest.writeParcelable(template, flags);
+        dest.writeLong(thresholdInBytes);
+    }
+
+    public static final @android.annotation.NonNull Creator<DataUsageRequest> CREATOR =
+            new Creator<DataUsageRequest>() {
+                @Override
+                public DataUsageRequest createFromParcel(Parcel in) {
+                    int requestId = in.readInt();
+                    NetworkTemplate template = in.readParcelable(null, android.net.NetworkTemplate.class);
+                    long thresholdInBytes = in.readLong();
+                    DataUsageRequest result = new DataUsageRequest(requestId, template,
+                            thresholdInBytes);
+                    return result;
+                }
+
+                @Override
+                public DataUsageRequest[] newArray(int size) {
+                    return new DataUsageRequest[size];
+                }
+            };
+
+    @Override
+    public String toString() {
+        return "DataUsageRequest [ requestId=" + requestId
+                + ", networkTemplate=" + template
+                + ", thresholdInBytes=" + thresholdInBytes + " ]";
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj instanceof DataUsageRequest == false) return false;
+        DataUsageRequest that = (DataUsageRequest) obj;
+        return that.requestId == this.requestId
+                && Objects.equals(that.template, this.template)
+                && that.thresholdInBytes == this.thresholdInBytes;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(requestId, template, thresholdInBytes);
+   }
+
+}
diff --git a/framework-t/src/android/net/EthernetManager.java b/framework-t/src/android/net/EthernetManager.java
new file mode 100644
index 0000000..e02ea89
--- /dev/null
+++ b/framework-t/src/android/net/EthernetManager.java
@@ -0,0 +1,729 @@
+/*
+ * Copyright (C) 2014 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+
+import android.annotation.CallbackExecutor;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresFeature;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.annotation.SystemService;
+import android.compat.annotation.UnsupportedAppUsage;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.OutcomeReceiver;
+import android.os.RemoteException;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.modules.utils.BackgroundThread;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.function.IntConsumer;
+
+/**
+ * A class that manages and configures Ethernet interfaces.
+ *
+ * @hide
+ */
+@SystemApi
+@SystemService(Context.ETHERNET_SERVICE)
+public class EthernetManager {
+    private static final String TAG = "EthernetManager";
+
+    private final IEthernetManager mService;
+    @GuardedBy("mListenerLock")
+    private final ArrayList<ListenerInfo<InterfaceStateListener>> mIfaceListeners =
+            new ArrayList<>();
+    @GuardedBy("mListenerLock")
+    private final ArrayList<ListenerInfo<IntConsumer>> mEthernetStateListeners =
+            new ArrayList<>();
+    final Object mListenerLock = new Object();
+    private final IEthernetServiceListener.Stub mServiceListener =
+            new IEthernetServiceListener.Stub() {
+                @Override
+                public void onEthernetStateChanged(int state) {
+                    synchronized (mListenerLock) {
+                        for (ListenerInfo<IntConsumer> li : mEthernetStateListeners) {
+                            li.executor.execute(() -> {
+                                li.listener.accept(state);
+                            });
+                        }
+                    }
+                }
+
+                @Override
+                public void onInterfaceStateChanged(String iface, int state, int role,
+                        IpConfiguration configuration) {
+                    synchronized (mListenerLock) {
+                        for (ListenerInfo<InterfaceStateListener> li : mIfaceListeners) {
+                            li.executor.execute(() ->
+                                    li.listener.onInterfaceStateChanged(iface, state, role,
+                                            configuration));
+                        }
+                    }
+                }
+            };
+
+    /**
+     * Indicates that Ethernet is disabled.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int ETHERNET_STATE_DISABLED = 0;
+
+    /**
+     * Indicates that Ethernet is enabled.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int ETHERNET_STATE_ENABLED  = 1;
+
+    private static class ListenerInfo<T> {
+        @NonNull
+        public final Executor executor;
+        @NonNull
+        public final T listener;
+
+        private ListenerInfo(@NonNull Executor executor, @NonNull T listener) {
+            this.executor = executor;
+            this.listener = listener;
+        }
+    }
+
+    /**
+     * The interface is absent.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int STATE_ABSENT = 0;
+
+    /**
+     * The interface is present but link is down.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int STATE_LINK_DOWN = 1;
+
+    /**
+     * The interface is present and link is up.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int STATE_LINK_UP = 2;
+
+    /** @hide */
+    @IntDef(prefix = "STATE_", value = {STATE_ABSENT, STATE_LINK_DOWN, STATE_LINK_UP})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface InterfaceState {}
+
+    /**
+     * The interface currently does not have any specific role.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int ROLE_NONE = 0;
+
+    /**
+     * The interface is in client mode (e.g., connected to the Internet).
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int ROLE_CLIENT = 1;
+
+    /**
+     * Ethernet interface is in server mode (e.g., providing Internet access to tethered devices).
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int ROLE_SERVER = 2;
+
+    /** @hide */
+    @IntDef(prefix = "ROLE_", value = {ROLE_NONE, ROLE_CLIENT, ROLE_SERVER})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface Role {}
+
+    /**
+     * A listener that receives notifications about the state of Ethernet interfaces on the system.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public interface InterfaceStateListener {
+        /**
+         * Called when an Ethernet interface changes state.
+         *
+         * @param iface the name of the interface.
+         * @param state the current state of the interface, or {@link #STATE_ABSENT} if the
+         *              interface was removed.
+         * @param role whether the interface is in client mode or server mode.
+         * @param configuration the current IP configuration of the interface.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        void onInterfaceStateChanged(@NonNull String iface, @InterfaceState int state,
+                @Role int role, @Nullable IpConfiguration configuration);
+    }
+
+    /**
+     * A listener interface to receive notification on changes in Ethernet.
+     * This has never been a supported API. Use {@link InterfaceStateListener} instead.
+     * @hide
+     */
+    public interface Listener extends InterfaceStateListener {
+        /**
+         * Called when Ethernet port's availability is changed.
+         * @param iface Ethernet interface name
+         * @param isAvailable {@code true} if Ethernet port exists.
+         * @hide
+         */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        void onAvailabilityChanged(String iface, boolean isAvailable);
+
+        /** Default implementation for backwards compatibility. Only calls the legacy listener. */
+        default void onInterfaceStateChanged(@NonNull String iface, @InterfaceState int state,
+                @Role int role, @Nullable IpConfiguration configuration) {
+            onAvailabilityChanged(iface, (state >= STATE_LINK_UP));
+        }
+
+    }
+
+    /**
+     * Create a new EthernetManager instance.
+     * Applications will almost always want to use
+     * {@link android.content.Context#getSystemService Context.getSystemService()} to retrieve
+     * the standard {@link android.content.Context#ETHERNET_SERVICE Context.ETHERNET_SERVICE}.
+     * @hide
+     */
+    public EthernetManager(Context context, IEthernetManager service) {
+        mService = service;
+    }
+
+    /**
+     * Get Ethernet configuration.
+     * @return the Ethernet Configuration, contained in {@link IpConfiguration}.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public IpConfiguration getConfiguration(String iface) {
+        try {
+            return mService.getConfiguration(iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Set Ethernet configuration.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public void setConfiguration(@NonNull String iface, @NonNull IpConfiguration config) {
+        try {
+            mService.setConfiguration(iface, config);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Indicates whether the system currently has one or more Ethernet interfaces.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public boolean isAvailable() {
+        return getAvailableInterfaces().length > 0;
+    }
+
+    /**
+     * Indicates whether the system has given interface.
+     *
+     * @param iface Ethernet interface name
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public boolean isAvailable(String iface) {
+        try {
+            return mService.isAvailable(iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Adds a listener.
+     * This has never been a supported API. Use {@link #addInterfaceStateListener} instead.
+     *
+     * @param listener A {@link Listener} to add.
+     * @throws IllegalArgumentException If the listener is null.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public void addListener(@NonNull Listener listener) {
+        addListener(listener, BackgroundThread.getExecutor());
+    }
+
+    /**
+     * Adds a listener.
+     * This has never been a supported API. Use {@link #addInterfaceStateListener} instead.
+     *
+     * @param listener A {@link Listener} to add.
+     * @param executor Executor to run callbacks on.
+     * @throws IllegalArgumentException If the listener or executor is null.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public void addListener(@NonNull Listener listener, @NonNull Executor executor) {
+        addInterfaceStateListener(executor, listener);
+    }
+
+    /**
+     * Listen to changes in the state of Ethernet interfaces.
+     *
+     * Adds a listener to receive notification for any state change of all existing Ethernet
+     * interfaces.
+     * <p>{@link Listener#onInterfaceStateChanged} will be triggered immediately for all
+     * existing interfaces upon adding a listener. The same method will be called on the
+     * listener every time any of the interface changes state. In particular, if an
+     * interface is removed, it will be called with state {@link #STATE_ABSENT}.
+     * <p>Use {@link #removeInterfaceStateListener} with the same object to stop listening.
+     *
+     * @param executor Executor to run callbacks on.
+     * @param listener A {@link Listener} to add.
+     * @hide
+     */
+    @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
+    @SystemApi(client = MODULE_LIBRARIES)
+    public void addInterfaceStateListener(@NonNull Executor executor,
+            @NonNull InterfaceStateListener listener) {
+        if (listener == null || executor == null) {
+            throw new NullPointerException("listener and executor must not be null");
+        }
+        synchronized (mListenerLock) {
+            maybeAddServiceListener();
+            mIfaceListeners.add(new ListenerInfo<InterfaceStateListener>(executor, listener));
+        }
+    }
+
+    @GuardedBy("mListenerLock")
+    private void maybeAddServiceListener() {
+        if (!mIfaceListeners.isEmpty() || !mEthernetStateListeners.isEmpty()) return;
+
+        try {
+            mService.addListener(mServiceListener);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+
+    }
+
+    /**
+     * Returns an array of available Ethernet interface names.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public String[] getAvailableInterfaces() {
+        try {
+            return mService.getAvailableInterfaces();
+        } catch (RemoteException e) {
+            throw e.rethrowAsRuntimeException();
+        }
+    }
+
+    /**
+     * Removes a listener.
+     *
+     * @param listener A {@link Listener} to remove.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public void removeInterfaceStateListener(@NonNull InterfaceStateListener listener) {
+        Objects.requireNonNull(listener);
+        synchronized (mListenerLock) {
+            mIfaceListeners.removeIf(l -> l.listener == listener);
+            maybeRemoveServiceListener();
+        }
+    }
+
+    @GuardedBy("mListenerLock")
+    private void maybeRemoveServiceListener() {
+        if (!mIfaceListeners.isEmpty() || !mEthernetStateListeners.isEmpty()) return;
+
+        try {
+            mService.removeListener(mServiceListener);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Removes a listener.
+     * This has never been a supported API. Use {@link #removeInterfaceStateListener} instead.
+     * @param listener A {@link Listener} to remove.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public void removeListener(@NonNull Listener listener) {
+        if (listener == null) {
+            throw new IllegalArgumentException("listener must not be null");
+        }
+        removeInterfaceStateListener(listener);
+    }
+
+    /**
+     * Whether to treat interfaces created by {@link TestNetworkManager#createTapInterface}
+     * as Ethernet interfaces. The effects of this method apply to any test interfaces that are
+     * already present on the system.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public void setIncludeTestInterfaces(boolean include) {
+        try {
+            mService.setIncludeTestInterfaces(include);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * A request for a tethered interface.
+     */
+    public static class TetheredInterfaceRequest {
+        private final IEthernetManager mService;
+        private final ITetheredInterfaceCallback mCb;
+
+        private TetheredInterfaceRequest(@NonNull IEthernetManager service,
+                @NonNull ITetheredInterfaceCallback cb) {
+            this.mService = service;
+            this.mCb = cb;
+        }
+
+        /**
+         * Release the request, causing the interface to revert back from tethering mode if there
+         * is no other requestor.
+         */
+        public void release() {
+            try {
+                mService.releaseTetheredInterface(mCb);
+            } catch (RemoteException e) {
+                e.rethrowFromSystemServer();
+            }
+        }
+    }
+
+    /**
+     * Callback for {@link #requestTetheredInterface(TetheredInterfaceCallback)}.
+     */
+    public interface TetheredInterfaceCallback {
+        /**
+         * Called when the tethered interface is available.
+         * @param iface The name of the interface.
+         */
+        void onAvailable(@NonNull String iface);
+
+        /**
+         * Called when the tethered interface is now unavailable.
+         */
+        void onUnavailable();
+    }
+
+    /**
+     * Request a tethered interface in tethering mode.
+     *
+     * <p>When this method is called and there is at least one ethernet interface available, the
+     * system will designate one to act as a tethered interface. If there is already a tethered
+     * interface, the existing interface will be used.
+     * @param callback A callback to be called once the request has been fulfilled.
+     */
+    @RequiresPermission(anyOf = {
+            android.Manifest.permission.NETWORK_STACK,
+            android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK
+    })
+    @NonNull
+    public TetheredInterfaceRequest requestTetheredInterface(@NonNull final Executor executor,
+            @NonNull final TetheredInterfaceCallback callback) {
+        Objects.requireNonNull(callback, "Callback must be non-null");
+        Objects.requireNonNull(executor, "Executor must be non-null");
+        final ITetheredInterfaceCallback cbInternal = new ITetheredInterfaceCallback.Stub() {
+            @Override
+            public void onAvailable(String iface) {
+                executor.execute(() -> callback.onAvailable(iface));
+            }
+
+            @Override
+            public void onUnavailable() {
+                executor.execute(() -> callback.onUnavailable());
+            }
+        };
+
+        try {
+            mService.requestTetheredInterface(cbInternal);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+        return new TetheredInterfaceRequest(mService, cbInternal);
+    }
+
+    private static final class NetworkInterfaceOutcomeReceiver
+            extends INetworkInterfaceOutcomeReceiver.Stub {
+        @NonNull
+        private final Executor mExecutor;
+        @NonNull
+        private final OutcomeReceiver<String, EthernetNetworkManagementException> mCallback;
+
+        NetworkInterfaceOutcomeReceiver(
+                @NonNull final Executor executor,
+                @NonNull final OutcomeReceiver<String, EthernetNetworkManagementException>
+                        callback) {
+            Objects.requireNonNull(executor, "Pass a non-null executor");
+            Objects.requireNonNull(callback, "Pass a non-null callback");
+            mExecutor = executor;
+            mCallback = callback;
+        }
+
+        @Override
+        public void onResult(@NonNull String iface) {
+            mExecutor.execute(() -> mCallback.onResult(iface));
+        }
+
+        @Override
+        public void onError(@NonNull EthernetNetworkManagementException e) {
+            mExecutor.execute(() -> mCallback.onError(e));
+        }
+    }
+
+    private NetworkInterfaceOutcomeReceiver makeNetworkInterfaceOutcomeReceiver(
+            @Nullable final Executor executor,
+            @Nullable final OutcomeReceiver<String, EthernetNetworkManagementException> callback) {
+        if (null != callback) {
+            Objects.requireNonNull(executor, "Pass a non-null executor, or a null callback");
+        }
+        final NetworkInterfaceOutcomeReceiver proxy;
+        if (null == callback) {
+            proxy = null;
+        } else {
+            proxy = new NetworkInterfaceOutcomeReceiver(executor, callback);
+        }
+        return proxy;
+    }
+
+    /**
+     * Updates the configuration of an automotive device's ethernet network.
+     *
+     * The {@link EthernetNetworkUpdateRequest} {@code request} argument describes how to update the
+     * configuration for this network.
+     * Use {@link StaticIpConfiguration.Builder} to build a {@code StaticIpConfiguration} object for
+     * this network to put inside the {@code request}.
+     * Similarly, use {@link NetworkCapabilities.Builder} to build a {@code NetworkCapabilities}
+     * object for this network to put inside the {@code request}.
+     *
+     * This function accepts an {@link OutcomeReceiver} that is called once the operation has
+     * finished execution.
+     *
+     * @param iface the name of the interface to act upon.
+     * @param request the {@link EthernetNetworkUpdateRequest} used to set an ethernet network's
+     *                {@link StaticIpConfiguration} and {@link NetworkCapabilities} values.
+     * @param executor an {@link Executor} to execute the callback on. Optional if callback is null.
+     * @param callback an optional {@link OutcomeReceiver} to listen for completion of the
+     *                 operation. On success, {@link OutcomeReceiver#onResult} is called with the
+     *                 interface name. On error, {@link OutcomeReceiver#onError} is called with more
+     *                 information about the error.
+     * @throws SecurityException if the process doesn't hold
+     *                          {@link android.Manifest.permission.MANAGE_ETHERNET_NETWORKS}.
+     * @throws UnsupportedOperationException if called on a non-automotive device or on an
+     *                                       unsupported interface.
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK,
+            android.Manifest.permission.MANAGE_ETHERNET_NETWORKS})
+    public void updateConfiguration(
+            @NonNull String iface,
+            @NonNull EthernetNetworkUpdateRequest request,
+            @Nullable @CallbackExecutor Executor executor,
+            @Nullable OutcomeReceiver<String, EthernetNetworkManagementException> callback) {
+        Objects.requireNonNull(iface, "iface must be non-null");
+        Objects.requireNonNull(request, "request must be non-null");
+        final NetworkInterfaceOutcomeReceiver proxy = makeNetworkInterfaceOutcomeReceiver(
+                executor, callback);
+        try {
+            mService.updateConfiguration(iface, request, proxy);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Enable a network interface.
+     *
+     * Enables a previously disabled network interface.
+     * This function accepts an {@link OutcomeReceiver} that is called once the operation has
+     * finished execution.
+     *
+     * @param iface the name of the interface to enable.
+     * @param executor an {@link Executor} to execute the callback on. Optional if callback is null.
+     * @param callback an optional {@link OutcomeReceiver} to listen for completion of the
+     *                 operation. On success, {@link OutcomeReceiver#onResult} is called with the
+     *                 interface name. On error, {@link OutcomeReceiver#onError} is called with more
+     *                 information about the error.
+     * @throws SecurityException if the process doesn't hold
+     *                          {@link android.Manifest.permission.MANAGE_ETHERNET_NETWORKS}.
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK,
+            android.Manifest.permission.MANAGE_ETHERNET_NETWORKS})
+    @RequiresFeature(PackageManager.FEATURE_AUTOMOTIVE)
+    public void enableInterface(
+            @NonNull String iface,
+            @Nullable @CallbackExecutor Executor executor,
+            @Nullable OutcomeReceiver<String, EthernetNetworkManagementException> callback) {
+        Objects.requireNonNull(iface, "iface must be non-null");
+        final NetworkInterfaceOutcomeReceiver proxy = makeNetworkInterfaceOutcomeReceiver(
+                executor, callback);
+        try {
+            mService.connectNetwork(iface, proxy);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Disable a network interface.
+     *
+     * Disables the use of a network interface to fulfill network requests. If the interface
+     * currently serves a request, the network will be torn down.
+     * This function accepts an {@link OutcomeReceiver} that is called once the operation has
+     * finished execution.
+     *
+     * @param iface the name of the interface to disable.
+     * @param executor an {@link Executor} to execute the callback on. Optional if callback is null.
+     * @param callback an optional {@link OutcomeReceiver} to listen for completion of the
+     *                 operation. On success, {@link OutcomeReceiver#onResult} is called with the
+     *                 interface name. On error, {@link OutcomeReceiver#onError} is called with more
+     *                 information about the error.
+     * @throws SecurityException if the process doesn't hold
+     *                          {@link android.Manifest.permission.MANAGE_ETHERNET_NETWORKS}.
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK,
+            android.Manifest.permission.MANAGE_ETHERNET_NETWORKS})
+    @RequiresFeature(PackageManager.FEATURE_AUTOMOTIVE)
+    public void disableInterface(
+            @NonNull String iface,
+            @Nullable @CallbackExecutor Executor executor,
+            @Nullable OutcomeReceiver<String, EthernetNetworkManagementException> callback) {
+        Objects.requireNonNull(iface, "iface must be non-null");
+        final NetworkInterfaceOutcomeReceiver proxy = makeNetworkInterfaceOutcomeReceiver(
+                executor, callback);
+        try {
+            mService.disconnectNetwork(iface, proxy);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Change ethernet setting.
+     *
+     * @param enabled enable or disable ethernet settings.
+     *
+     * @hide
+     */
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_STACK,
+            android.Manifest.permission.NETWORK_SETTINGS})
+    @SystemApi(client = MODULE_LIBRARIES)
+    public void setEthernetEnabled(boolean enabled) {
+        try {
+            mService.setEthernetEnabled(enabled);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Listen to changes in the state of ethernet.
+     *
+     * @param executor to run callbacks on.
+     * @param listener to listen ethernet state changed.
+     *
+     * @hide
+     */
+    @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
+    @SystemApi(client = MODULE_LIBRARIES)
+    public void addEthernetStateListener(@NonNull Executor executor,
+            @NonNull IntConsumer listener) {
+        Objects.requireNonNull(executor);
+        Objects.requireNonNull(listener);
+        synchronized (mListenerLock) {
+            maybeAddServiceListener();
+            mEthernetStateListeners.add(new ListenerInfo<IntConsumer>(executor, listener));
+        }
+    }
+
+    /**
+     * Removes a listener.
+     *
+     * @param listener to listen ethernet state changed.
+     *
+     * @hide
+     */
+    @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
+    @SystemApi(client = MODULE_LIBRARIES)
+    public void removeEthernetStateListener(@NonNull IntConsumer listener) {
+        Objects.requireNonNull(listener);
+        synchronized (mListenerLock) {
+            mEthernetStateListeners.removeIf(l -> l.listener == listener);
+            maybeRemoveServiceListener();
+        }
+    }
+
+    /**
+     * Returns an array of existing Ethernet interface names regardless whether the interface
+     * is available or not currently.
+     * @hide
+     */
+    @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
+    @SystemApi(client = MODULE_LIBRARIES)
+    @NonNull
+    public List<String> getInterfaceList() {
+        try {
+            return mService.getInterfaceList();
+        } catch (RemoteException e) {
+            throw e.rethrowAsRuntimeException();
+        }
+    }
+}
diff --git a/framework-t/src/android/net/EthernetNetworkManagementException.aidl b/framework-t/src/android/net/EthernetNetworkManagementException.aidl
new file mode 100644
index 0000000..adf9e5a
--- /dev/null
+++ b/framework-t/src/android/net/EthernetNetworkManagementException.aidl
@@ -0,0 +1,19 @@
+/*
+ * 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 android.net;
+
+ parcelable EthernetNetworkManagementException;
\ No newline at end of file
diff --git a/framework-t/src/android/net/EthernetNetworkManagementException.java b/framework-t/src/android/net/EthernetNetworkManagementException.java
new file mode 100644
index 0000000..a69cc55
--- /dev/null
+++ b/framework-t/src/android/net/EthernetNetworkManagementException.java
@@ -0,0 +1,73 @@
+/*
+ * 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 android.net;
+
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.Objects;
+
+/** @hide */
+@SystemApi
+public final class EthernetNetworkManagementException
+        extends RuntimeException implements Parcelable {
+
+    /* @hide */
+    public EthernetNetworkManagementException(@NonNull final String errorMessage) {
+        super(errorMessage);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(getMessage());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) return true;
+        if (obj == null || getClass() != obj.getClass()) return false;
+        final EthernetNetworkManagementException that = (EthernetNetworkManagementException) obj;
+
+        return Objects.equals(getMessage(), that.getMessage());
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeString(getMessage());
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @NonNull
+    public static final Parcelable.Creator<EthernetNetworkManagementException> CREATOR =
+            new Parcelable.Creator<EthernetNetworkManagementException>() {
+                @Override
+                public EthernetNetworkManagementException[] newArray(int size) {
+                    return new EthernetNetworkManagementException[size];
+                }
+
+                @Override
+                public EthernetNetworkManagementException createFromParcel(@NonNull Parcel source) {
+                    return new EthernetNetworkManagementException(source.readString());
+                }
+            };
+}
diff --git a/framework-t/src/android/net/EthernetNetworkSpecifier.java b/framework-t/src/android/net/EthernetNetworkSpecifier.java
new file mode 100644
index 0000000..e4d6e24
--- /dev/null
+++ b/framework-t/src/android/net/EthernetNetworkSpecifier.java
@@ -0,0 +1,102 @@
+/*
+ * 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 android.net;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import java.util.Objects;
+
+/**
+ * A {@link NetworkSpecifier} used to identify ethernet interfaces.
+ *
+ * @see EthernetManager
+ */
+public final class EthernetNetworkSpecifier extends NetworkSpecifier implements Parcelable {
+
+    /**
+     * Name of the network interface.
+     */
+    @NonNull
+    private final String mInterfaceName;
+
+    /**
+     * Create a new EthernetNetworkSpecifier.
+     * @param interfaceName Name of the ethernet interface the specifier refers to.
+     */
+    public EthernetNetworkSpecifier(@NonNull String interfaceName) {
+        if (TextUtils.isEmpty(interfaceName)) {
+            throw new IllegalArgumentException();
+        }
+        mInterfaceName = interfaceName;
+    }
+
+    /**
+     * Get the name of the ethernet interface the specifier refers to.
+     */
+    @Nullable
+    public String getInterfaceName() {
+        // This may be null in the future to support specifiers based on data other than the
+        // interface name.
+        return mInterfaceName;
+    }
+
+    /** @hide */
+    @Override
+    public boolean canBeSatisfiedBy(@Nullable NetworkSpecifier other) {
+        return equals(other);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (!(o instanceof EthernetNetworkSpecifier)) return false;
+        return TextUtils.equals(mInterfaceName, ((EthernetNetworkSpecifier) o).mInterfaceName);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mInterfaceName);
+    }
+
+    @Override
+    public String toString() {
+        return "EthernetNetworkSpecifier (" + mInterfaceName + ")";
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeString(mInterfaceName);
+    }
+
+    public static final @NonNull Parcelable.Creator<EthernetNetworkSpecifier> CREATOR =
+            new Parcelable.Creator<EthernetNetworkSpecifier>() {
+        public EthernetNetworkSpecifier createFromParcel(Parcel in) {
+            return new EthernetNetworkSpecifier(in.readString());
+        }
+        public EthernetNetworkSpecifier[] newArray(int size) {
+            return new EthernetNetworkSpecifier[size];
+        }
+    };
+}
diff --git a/framework-t/src/android/net/EthernetNetworkUpdateRequest.aidl b/framework-t/src/android/net/EthernetNetworkUpdateRequest.aidl
new file mode 100644
index 0000000..debc348
--- /dev/null
+++ b/framework-t/src/android/net/EthernetNetworkUpdateRequest.aidl
@@ -0,0 +1,19 @@
+/*
+ * 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 android.net;
+
+ parcelable EthernetNetworkUpdateRequest;
\ No newline at end of file
diff --git a/framework-t/src/android/net/EthernetNetworkUpdateRequest.java b/framework-t/src/android/net/EthernetNetworkUpdateRequest.java
new file mode 100644
index 0000000..1691942
--- /dev/null
+++ b/framework-t/src/android/net/EthernetNetworkUpdateRequest.java
@@ -0,0 +1,185 @@
+/*
+ * 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 android.net;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.Objects;
+
+/**
+ * Represents a request to update an existing Ethernet interface.
+ *
+ * @see EthernetManager#updateConfiguration
+ *
+ * @hide
+ */
+@SystemApi
+public final class EthernetNetworkUpdateRequest implements Parcelable {
+    @Nullable
+    private final IpConfiguration mIpConfig;
+    @Nullable
+    private final NetworkCapabilities mNetworkCapabilities;
+
+    /**
+     * Setting the {@link IpConfiguration} is optional in {@link EthernetNetworkUpdateRequest}.
+     * When set to null, the existing IpConfiguration is not updated.
+     *
+     * @return the new {@link IpConfiguration} or null.
+     */
+    @Nullable
+    public IpConfiguration getIpConfiguration() {
+        return mIpConfig == null ? null : new IpConfiguration(mIpConfig);
+    }
+
+    /**
+     * Setting the {@link NetworkCapabilities} is optional in {@link EthernetNetworkUpdateRequest}.
+     * When set to null, the existing NetworkCapabilities are not updated.
+     *
+     * @return the new {@link NetworkCapabilities} or null.
+     */
+    @Nullable
+    public NetworkCapabilities getNetworkCapabilities() {
+        return mNetworkCapabilities == null ? null : new NetworkCapabilities(mNetworkCapabilities);
+    }
+
+    private EthernetNetworkUpdateRequest(@Nullable final IpConfiguration ipConfig,
+            @Nullable final NetworkCapabilities networkCapabilities) {
+        mIpConfig = ipConfig;
+        mNetworkCapabilities = networkCapabilities;
+    }
+
+    private EthernetNetworkUpdateRequest(@NonNull final Parcel source) {
+        Objects.requireNonNull(source);
+        mIpConfig = source.readParcelable(IpConfiguration.class.getClassLoader(),
+                IpConfiguration.class);
+        mNetworkCapabilities = source.readParcelable(NetworkCapabilities.class.getClassLoader(),
+                NetworkCapabilities.class);
+    }
+
+    /**
+     * Builder used to create {@link EthernetNetworkUpdateRequest} objects.
+     */
+    public static final class Builder {
+        @Nullable
+        private IpConfiguration mBuilderIpConfig;
+        @Nullable
+        private NetworkCapabilities mBuilderNetworkCapabilities;
+
+        public Builder(){}
+
+        /**
+         * Constructor to populate the builder's values with an already built
+         * {@link EthernetNetworkUpdateRequest}.
+         * @param request the {@link EthernetNetworkUpdateRequest} to populate with.
+         */
+        public Builder(@NonNull final EthernetNetworkUpdateRequest request) {
+            Objects.requireNonNull(request);
+            mBuilderIpConfig = null == request.mIpConfig
+                    ? null : new IpConfiguration(request.mIpConfig);
+            mBuilderNetworkCapabilities = null == request.mNetworkCapabilities
+                    ? null : new NetworkCapabilities(request.mNetworkCapabilities);
+        }
+
+        /**
+         * Set the {@link IpConfiguration} to be used with the {@code Builder}.
+         * @param ipConfig the {@link IpConfiguration} to set.
+         * @return The builder to facilitate chaining.
+         */
+        @NonNull
+        public Builder setIpConfiguration(@Nullable final IpConfiguration ipConfig) {
+            mBuilderIpConfig = ipConfig == null ? null : new IpConfiguration(ipConfig);
+            return this;
+        }
+
+        /**
+         * Set the {@link NetworkCapabilities} to be used with the {@code Builder}.
+         * @param nc the {@link NetworkCapabilities} to set.
+         * @return The builder to facilitate chaining.
+         */
+        @NonNull
+        public Builder setNetworkCapabilities(@Nullable final NetworkCapabilities nc) {
+            mBuilderNetworkCapabilities = nc == null ? null : new NetworkCapabilities(nc);
+            return this;
+        }
+
+        /**
+         * Build {@link EthernetNetworkUpdateRequest} return the current update request.
+         *
+         * @throws IllegalStateException when both mBuilderNetworkCapabilities and mBuilderIpConfig
+         *                               are null.
+         */
+        @NonNull
+        public EthernetNetworkUpdateRequest build() {
+            if (mBuilderIpConfig == null && mBuilderNetworkCapabilities == null) {
+                throw new IllegalStateException(
+                        "Cannot construct an empty EthernetNetworkUpdateRequest");
+            }
+            return new EthernetNetworkUpdateRequest(mBuilderIpConfig, mBuilderNetworkCapabilities);
+        }
+    }
+
+    @Override
+    public String toString() {
+        return "EthernetNetworkUpdateRequest{"
+                + "mIpConfig=" + mIpConfig
+                + ", mNetworkCapabilities=" + mNetworkCapabilities + '}';
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        EthernetNetworkUpdateRequest that = (EthernetNetworkUpdateRequest) o;
+
+        return Objects.equals(that.getIpConfiguration(), mIpConfig)
+                && Objects.equals(that.getNetworkCapabilities(), mNetworkCapabilities);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mIpConfig, mNetworkCapabilities);
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeParcelable(mIpConfig, flags);
+        dest.writeParcelable(mNetworkCapabilities, flags);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @NonNull
+    public static final Parcelable.Creator<EthernetNetworkUpdateRequest> CREATOR =
+            new Parcelable.Creator<EthernetNetworkUpdateRequest>() {
+                @Override
+                public EthernetNetworkUpdateRequest[] newArray(int size) {
+                    return new EthernetNetworkUpdateRequest[size];
+                }
+
+                @Override
+                public EthernetNetworkUpdateRequest createFromParcel(@NonNull Parcel source) {
+                    return new EthernetNetworkUpdateRequest(source);
+                }
+            };
+}
diff --git a/framework-t/src/android/net/IEthernetManager.aidl b/framework-t/src/android/net/IEthernetManager.aidl
new file mode 100644
index 0000000..42e4c1a
--- /dev/null
+++ b/framework-t/src/android/net/IEthernetManager.aidl
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2014 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.IpConfiguration;
+import android.net.IEthernetServiceListener;
+import android.net.EthernetNetworkManagementException;
+import android.net.EthernetNetworkUpdateRequest;
+import android.net.INetworkInterfaceOutcomeReceiver;
+import android.net.ITetheredInterfaceCallback;
+
+import java.util.List;
+
+/**
+ * Interface that answers queries about, and allows changing
+ * ethernet configuration.
+ */
+/** {@hide} */
+interface IEthernetManager
+{
+    String[] getAvailableInterfaces();
+    IpConfiguration getConfiguration(String iface);
+    void setConfiguration(String iface, in IpConfiguration config);
+    boolean isAvailable(String iface);
+    void addListener(in IEthernetServiceListener listener);
+    void removeListener(in IEthernetServiceListener listener);
+    void setIncludeTestInterfaces(boolean include);
+    void requestTetheredInterface(in ITetheredInterfaceCallback callback);
+    void releaseTetheredInterface(in ITetheredInterfaceCallback callback);
+    void updateConfiguration(String iface, in EthernetNetworkUpdateRequest request,
+        in INetworkInterfaceOutcomeReceiver listener);
+    void connectNetwork(String iface, in INetworkInterfaceOutcomeReceiver listener);
+    void disconnectNetwork(String iface, in INetworkInterfaceOutcomeReceiver listener);
+    void setEthernetEnabled(boolean enabled);
+    List<String> getInterfaceList();
+}
diff --git a/framework-t/src/android/net/IEthernetServiceListener.aidl b/framework-t/src/android/net/IEthernetServiceListener.aidl
new file mode 100644
index 0000000..751605b
--- /dev/null
+++ b/framework-t/src/android/net/IEthernetServiceListener.aidl
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2014 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.IpConfiguration;
+
+/** @hide */
+oneway interface IEthernetServiceListener
+{
+    void onEthernetStateChanged(int state);
+    void onInterfaceStateChanged(String iface, int state, int role,
+            in IpConfiguration configuration);
+}
diff --git a/framework-t/src/android/net/IIpSecService.aidl b/framework-t/src/android/net/IIpSecService.aidl
new file mode 100644
index 0000000..933256a
--- /dev/null
+++ b/framework-t/src/android/net/IIpSecService.aidl
@@ -0,0 +1,78 @@
+/*
+** Copyright 2017, 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.LinkAddress;
+import android.net.Network;
+import android.net.IpSecConfig;
+import android.net.IpSecUdpEncapResponse;
+import android.net.IpSecSpiResponse;
+import android.net.IpSecTransformResponse;
+import android.net.IpSecTunnelInterfaceResponse;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+
+/**
+ * @hide
+ */
+interface IIpSecService
+{
+    IpSecSpiResponse allocateSecurityParameterIndex(
+            in String destinationAddress, int requestedSpi, in IBinder binder);
+
+    void releaseSecurityParameterIndex(int resourceId);
+
+    IpSecUdpEncapResponse openUdpEncapsulationSocket(int port, in IBinder binder);
+
+    void closeUdpEncapsulationSocket(int resourceId);
+
+    IpSecTunnelInterfaceResponse createTunnelInterface(
+            in String localAddr,
+            in String remoteAddr,
+            in Network underlyingNetwork,
+            in IBinder binder,
+            in String callingPackage);
+
+    void addAddressToTunnelInterface(
+            int tunnelResourceId,
+            in LinkAddress localAddr,
+            in String callingPackage);
+
+    void removeAddressFromTunnelInterface(
+            int tunnelResourceId,
+            in LinkAddress localAddr,
+            in String callingPackage);
+
+    void setNetworkForTunnelInterface(
+            int tunnelResourceId, in Network underlyingNetwork, in String callingPackage);
+
+    void deleteTunnelInterface(int resourceId, in String callingPackage);
+
+    IpSecTransformResponse createTransform(
+            in IpSecConfig c, in IBinder binder, in String callingPackage);
+
+    void deleteTransform(int transformId);
+
+    void applyTransportModeTransform(
+            in ParcelFileDescriptor socket, int direction, int transformId);
+
+    void applyTunnelModeTransform(
+            int tunnelResourceId, int direction, int transformResourceId, in String callingPackage);
+
+    void removeTransportModeTransforms(in ParcelFileDescriptor socket);
+}
diff --git a/framework-t/src/android/net/INetworkInterfaceOutcomeReceiver.aidl b/framework-t/src/android/net/INetworkInterfaceOutcomeReceiver.aidl
new file mode 100644
index 0000000..85795ea
--- /dev/null
+++ b/framework-t/src/android/net/INetworkInterfaceOutcomeReceiver.aidl
@@ -0,0 +1,25 @@
+/**
+ * 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 android.net;
+
+import android.net.EthernetNetworkManagementException;
+
+/** @hide */
+oneway interface INetworkInterfaceOutcomeReceiver {
+    void onResult(in String iface);
+    void onError(in EthernetNetworkManagementException e);
+}
\ No newline at end of file
diff --git a/framework-t/src/android/net/INetworkStatsService.aidl b/framework-t/src/android/net/INetworkStatsService.aidl
new file mode 100644
index 0000000..c86f7fd
--- /dev/null
+++ b/framework-t/src/android/net/INetworkStatsService.aidl
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2011 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.DataUsageRequest;
+import android.net.INetworkStatsSession;
+import android.net.Network;
+import android.net.NetworkStateSnapshot;
+import android.net.NetworkStats;
+import android.net.NetworkStatsHistory;
+import android.net.NetworkTemplate;
+import android.net.UnderlyingNetworkInfo;
+import android.net.netstats.IUsageCallback;
+import android.net.netstats.provider.INetworkStatsProvider;
+import android.net.netstats.provider.INetworkStatsProviderCallback;
+import android.os.IBinder;
+import android.os.Messenger;
+
+/** {@hide} */
+interface INetworkStatsService {
+
+    /** Start a statistics query session. */
+    @UnsupportedAppUsage
+    INetworkStatsSession openSession();
+
+    /** Start a statistics query session. If calling package is profile or device owner then it is
+     *  granted automatic access if apiLevel is NetworkStatsManager.API_LEVEL_DPC_ALLOWED. If
+     *  apiLevel is at least NetworkStatsManager.API_LEVEL_REQUIRES_PACKAGE_USAGE_STATS then
+     *  PACKAGE_USAGE_STATS permission is always checked. If PACKAGE_USAGE_STATS is not granted
+     *  READ_NETWORK_USAGE_STATS is checked for.
+     */
+    @UnsupportedAppUsage(maxTargetSdk = 30, trackingBug = 170729553)
+    INetworkStatsSession openSessionForUsageStats(int flags, String callingPackage);
+
+    /** Return data layer snapshot of UID network usage. */
+    @UnsupportedAppUsage
+    NetworkStats getDataLayerSnapshotForUid(int uid);
+
+    /** Get the transport NetworkStats for all UIDs since boot. */
+    NetworkStats getUidStatsForTransport(int transport);
+
+    /** Return set of any ifaces associated with mobile networks since boot. */
+    @UnsupportedAppUsage
+    String[] getMobileIfaces();
+
+    /** Increment data layer count of operations performed for UID and tag. */
+    void incrementOperationCount(int uid, int tag, int operationCount);
+
+    /**  Notify {@code NetworkStatsService} about network status changed. */
+    void notifyNetworkStatus(
+         in Network[] defaultNetworks,
+         in NetworkStateSnapshot[] snapshots,
+         in String activeIface,
+         in UnderlyingNetworkInfo[] underlyingNetworkInfos);
+    /** Force update of statistics. */
+    @UnsupportedAppUsage
+    void forceUpdate();
+
+    /** Registers a callback on data usage. */
+    DataUsageRequest registerUsageCallback(String callingPackage,
+            in DataUsageRequest request, in IUsageCallback callback);
+
+    /** Unregisters a callback on data usage. */
+    void unregisterUsageRequest(in DataUsageRequest request);
+
+    /** Get the uid stats information since boot */
+    long getUidStats(int uid, int type);
+
+    /** Get the iface stats information since boot */
+    long getIfaceStats(String iface, int type);
+
+    /** Get the total network stats information since boot */
+    long getTotalStats(int type);
+
+    /** Registers a network stats provider */
+    INetworkStatsProviderCallback registerNetworkStatsProvider(String tag,
+            in INetworkStatsProvider provider);
+
+    /** Mark given UID as being in foreground for stats purposes. */
+    void noteUidForeground(int uid, boolean uidForeground);
+
+    /** Advise persistence threshold; may be overridden internally. */
+    void advisePersistThreshold(long thresholdBytes);
+
+    /**
+     * Set the warning and limit to all registered custom network stats providers.
+     * Note that invocation of any interface will be sent to all providers.
+     */
+     void setStatsProviderWarningAndLimitAsync(String iface, long warning, long limit);
+}
diff --git a/framework-t/src/android/net/INetworkStatsSession.aidl b/framework-t/src/android/net/INetworkStatsSession.aidl
new file mode 100644
index 0000000..ab70be8
--- /dev/null
+++ b/framework-t/src/android/net/INetworkStatsSession.aidl
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2012 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.NetworkStats;
+import android.net.NetworkStatsHistory;
+import android.net.NetworkTemplate;
+
+/** {@hide} */
+interface INetworkStatsSession {
+
+    /** Return device aggregated network layer usage summary for traffic that matches template. */
+    NetworkStats getDeviceSummaryForNetwork(in NetworkTemplate template, long start, long end);
+
+    /** Return network layer usage summary for traffic that matches template. */
+    @UnsupportedAppUsage
+    NetworkStats getSummaryForNetwork(in NetworkTemplate template, long start, long end);
+    /** Return historical network layer stats for traffic that matches template. */
+    @UnsupportedAppUsage
+    NetworkStatsHistory getHistoryForNetwork(in NetworkTemplate template, int fields);
+    /**
+     * Return historical network layer stats for traffic that matches template, start and end
+     * timestamp.
+     */
+    NetworkStatsHistory getHistoryIntervalForNetwork(in NetworkTemplate template, int fields, long start, long end);
+
+    /**
+     * Return network layer usage summary per UID for traffic that matches template.
+     *
+     * <p>The resulting {@code NetworkStats#getElapsedRealtime()} contains time delta between
+     * {@code start} and {@code end}.
+     *
+     * @param template - a predicate to filter netstats.
+     * @param start - start of the range, timestamp in milliseconds since the epoch.
+     * @param end - end of the range, timestamp in milliseconds since the epoch.
+     * @param includeTags - includes data usage tags if true.
+     */
+    @UnsupportedAppUsage
+    NetworkStats getSummaryForAllUid(in NetworkTemplate template, long start, long end, boolean includeTags);
+
+    /** Return network layer usage summary per UID for tagged traffic that matches template. */
+    NetworkStats getTaggedSummaryForAllUid(in NetworkTemplate template, long start, long end);
+
+    /** Return historical network layer stats for specific UID traffic that matches template. */
+    @UnsupportedAppUsage
+    NetworkStatsHistory getHistoryForUid(in NetworkTemplate template, int uid, int set, int tag, int fields);
+    /** Return historical network layer stats for specific UID traffic that matches template. */
+    NetworkStatsHistory getHistoryIntervalForUid(in NetworkTemplate template, int uid, int set, int tag, int fields, long start, long end);
+
+    /** Return array of uids that have stats and are accessible to the calling user */
+    int[] getRelevantUids();
+
+    @UnsupportedAppUsage
+    void close();
+
+}
diff --git a/framework-t/src/android/net/ITetheredInterfaceCallback.aidl b/framework-t/src/android/net/ITetheredInterfaceCallback.aidl
new file mode 100644
index 0000000..14aa023
--- /dev/null
+++ b/framework-t/src/android/net/ITetheredInterfaceCallback.aidl
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2020 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;
+
+/** @hide */
+oneway interface ITetheredInterfaceCallback {
+    void onAvailable(in String iface);
+    void onUnavailable();
+}
\ No newline at end of file
diff --git a/framework-t/src/android/net/IpSecAlgorithm.java b/framework-t/src/android/net/IpSecAlgorithm.java
new file mode 100644
index 0000000..10a22ac
--- /dev/null
+++ b/framework-t/src/android/net/IpSecAlgorithm.java
@@ -0,0 +1,491 @@
+/*
+ * Copyright (C) 2017 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.StringDef;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/**
+ * This class represents a single algorithm that can be used by an {@link IpSecTransform}.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc4301">RFC 4301, Security Architecture for the
+ * Internet Protocol</a>
+ */
+public final class IpSecAlgorithm implements Parcelable {
+    private static final String TAG = "IpSecAlgorithm";
+
+    /**
+     * Null cipher.
+     *
+     * @hide
+     */
+    public static final String CRYPT_NULL = "ecb(cipher_null)";
+
+    /**
+     * AES-CBC Encryption/Ciphering Algorithm.
+     *
+     * <p>Valid lengths for this key are {128, 192, 256}.
+     */
+    public static final String CRYPT_AES_CBC = "cbc(aes)";
+
+    /**
+     * AES-CTR Encryption/Ciphering Algorithm.
+     *
+     * <p>Valid lengths for keying material are {160, 224, 288}.
+     *
+     * <p>As per <a href="https://tools.ietf.org/html/rfc3686#section-5.1">RFC3686 (Section
+     * 5.1)</a>, keying material consists of a 128, 192, or 256 bit AES key followed by a 32-bit
+     * nonce. RFC compliance requires that the nonce must be unique per security association.
+     *
+     * <p>This algorithm may be available on the device. Caller MUST check if it is supported before
+     * using it by calling {@link #getSupportedAlgorithms()} and checking if this algorithm is
+     * included in the returned algorithm set. The returned algorithm set will not change unless the
+     * device is rebooted. {@link IllegalArgumentException} will be thrown if this algorithm is
+     * requested on an unsupported device.
+     *
+     * <p>@see {@link #getSupportedAlgorithms()}
+     */
+    // This algorithm may be available on devices released before Android 12, and is guaranteed
+    // to be available on devices first shipped with Android 12 or later.
+    public static final String CRYPT_AES_CTR = "rfc3686(ctr(aes))";
+
+    /**
+     * MD5 HMAC Authentication/Integrity Algorithm. <b>This algorithm is not recommended for use in
+     * new applications and is provided for legacy compatibility with 3gpp infrastructure.</b>
+     *
+     * <p>Keys for this algorithm must be 128 bits in length.
+     *
+     * <p>Valid truncation lengths are multiples of 8 bits from 96 to 128.
+     */
+    public static final String AUTH_HMAC_MD5 = "hmac(md5)";
+
+    /**
+     * SHA1 HMAC Authentication/Integrity Algorithm. <b>This algorithm is not recommended for use in
+     * new applications and is provided for legacy compatibility with 3gpp infrastructure.</b>
+     *
+     * <p>Keys for this algorithm must be 160 bits in length.
+     *
+     * <p>Valid truncation lengths are multiples of 8 bits from 96 to 160.
+     */
+    public static final String AUTH_HMAC_SHA1 = "hmac(sha1)";
+
+    /**
+     * SHA256 HMAC Authentication/Integrity Algorithm.
+     *
+     * <p>Keys for this algorithm must be 256 bits in length.
+     *
+     * <p>Valid truncation lengths are multiples of 8 bits from 96 to 256.
+     */
+    public static final String AUTH_HMAC_SHA256 = "hmac(sha256)";
+
+    /**
+     * SHA384 HMAC Authentication/Integrity Algorithm.
+     *
+     * <p>Keys for this algorithm must be 384 bits in length.
+     *
+     * <p>Valid truncation lengths are multiples of 8 bits from 192 to 384.
+     */
+    public static final String AUTH_HMAC_SHA384 = "hmac(sha384)";
+
+    /**
+     * SHA512 HMAC Authentication/Integrity Algorithm.
+     *
+     * <p>Keys for this algorithm must be 512 bits in length.
+     *
+     * <p>Valid truncation lengths are multiples of 8 bits from 256 to 512.
+     */
+    public static final String AUTH_HMAC_SHA512 = "hmac(sha512)";
+
+    /**
+     * AES-XCBC Authentication/Integrity Algorithm.
+     *
+     * <p>Keys for this algorithm must be 128 bits in length.
+     *
+     * <p>The only valid truncation length is 96 bits.
+     *
+     * <p>This algorithm may be available on the device. Caller MUST check if it is supported before
+     * using it by calling {@link #getSupportedAlgorithms()} and checking if this algorithm is
+     * included in the returned algorithm set. The returned algorithm set will not change unless the
+     * device is rebooted. {@link IllegalArgumentException} will be thrown if this algorithm is
+     * requested on an unsupported device.
+     *
+     * <p>@see {@link #getSupportedAlgorithms()}
+     */
+    // This algorithm may be available on devices released before Android 12, and is guaranteed
+    // to be available on devices first shipped with Android 12 or later.
+    public static final String AUTH_AES_XCBC = "xcbc(aes)";
+
+    /**
+     * AES-CMAC Authentication/Integrity Algorithm.
+     *
+     * <p>Keys for this algorithm must be 128 bits in length.
+     *
+     * <p>The only valid truncation length is 96 bits.
+     *
+     * <p>This algorithm may be available on the device. Caller MUST check if it is supported before
+     * using it by calling {@link #getSupportedAlgorithms()} and checking if this algorithm is
+     * included in the returned algorithm set. The returned algorithm set will not change unless the
+     * device is rebooted. {@link IllegalArgumentException} will be thrown if this algorithm is
+     * requested on an unsupported device.
+     *
+     * <p>@see {@link #getSupportedAlgorithms()}
+     */
+    // This algorithm may be available on devices released before Android 12, and is guaranteed
+    // to be available on devices first shipped with Android 12 or later.
+    public static final String AUTH_AES_CMAC = "cmac(aes)";
+
+    /**
+     * AES-GCM Authentication/Integrity + Encryption/Ciphering Algorithm.
+     *
+     * <p>Valid lengths for keying material are {160, 224, 288}.
+     *
+     * <p>As per <a href="https://tools.ietf.org/html/rfc4106#section-8.1">RFC4106 (Section
+     * 8.1)</a>, keying material consists of a 128, 192, or 256 bit AES key followed by a 32-bit
+     * salt. RFC compliance requires that the salt must be unique per invocation with the same key.
+     *
+     * <p>Valid ICV (truncation) lengths are {64, 96, 128}.
+     */
+    public static final String AUTH_CRYPT_AES_GCM = "rfc4106(gcm(aes))";
+
+    /**
+     * ChaCha20-Poly1305 Authentication/Integrity + Encryption/Ciphering Algorithm.
+     *
+     * <p>Keys for this algorithm must be 288 bits in length.
+     *
+     * <p>As per <a href="https://tools.ietf.org/html/rfc7634#section-2">RFC7634 (Section 2)</a>,
+     * keying material consists of a 256 bit key followed by a 32-bit salt. The salt is fixed per
+     * security association.
+     *
+     * <p>The only valid ICV (truncation) length is 128 bits.
+     *
+     * <p>This algorithm may be available on the device. Caller MUST check if it is supported before
+     * using it by calling {@link #getSupportedAlgorithms()} and checking if this algorithm is
+     * included in the returned algorithm set. The returned algorithm set will not change unless the
+     * device is rebooted. {@link IllegalArgumentException} will be thrown if this algorithm is
+     * requested on an unsupported device.
+     *
+     * <p>@see {@link #getSupportedAlgorithms()}
+     */
+    // This algorithm may be available on devices released before Android 12, and is guaranteed
+    // to be available on devices first shipped with Android 12 or later.
+    public static final String AUTH_CRYPT_CHACHA20_POLY1305 = "rfc7539esp(chacha20,poly1305)";
+
+    /** @hide */
+    @StringDef({
+        CRYPT_AES_CBC,
+        CRYPT_AES_CTR,
+        AUTH_HMAC_MD5,
+        AUTH_HMAC_SHA1,
+        AUTH_HMAC_SHA256,
+        AUTH_HMAC_SHA384,
+        AUTH_HMAC_SHA512,
+        AUTH_AES_XCBC,
+        AUTH_AES_CMAC,
+        AUTH_CRYPT_AES_GCM,
+        AUTH_CRYPT_CHACHA20_POLY1305
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface AlgorithmName {}
+
+    /** @hide */
+    @VisibleForTesting
+    public static final Map<String, Integer> ALGO_TO_REQUIRED_FIRST_SDK = new HashMap<>();
+
+    private static final int SDK_VERSION_ZERO = 0;
+
+    static {
+        ALGO_TO_REQUIRED_FIRST_SDK.put(CRYPT_AES_CBC, SDK_VERSION_ZERO);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_HMAC_MD5, SDK_VERSION_ZERO);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_HMAC_SHA1, SDK_VERSION_ZERO);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_HMAC_SHA256, SDK_VERSION_ZERO);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_HMAC_SHA384, SDK_VERSION_ZERO);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_HMAC_SHA512, SDK_VERSION_ZERO);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_CRYPT_AES_GCM, SDK_VERSION_ZERO);
+
+        ALGO_TO_REQUIRED_FIRST_SDK.put(CRYPT_AES_CTR, Build.VERSION_CODES.S);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_AES_XCBC, Build.VERSION_CODES.S);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_AES_CMAC, Build.VERSION_CODES.S);
+        ALGO_TO_REQUIRED_FIRST_SDK.put(AUTH_CRYPT_CHACHA20_POLY1305, Build.VERSION_CODES.S);
+    }
+
+    private static final Set<String> ENABLED_ALGOS =
+            Collections.unmodifiableSet(loadAlgos(Resources.getSystem()));
+
+    private final String mName;
+    private final byte[] mKey;
+    private final int mTruncLenBits;
+
+    /**
+     * Creates an IpSecAlgorithm of one of the supported types. Supported algorithm names are
+     * defined as constants in this class.
+     *
+     * <p>For algorithms that produce an integrity check value, the truncation length is a required
+     * parameter. See {@link #IpSecAlgorithm(String algorithm, byte[] key, int truncLenBits)}
+     *
+     * @param algorithm name of the algorithm.
+     * @param key key padded to a multiple of 8 bits.
+     * @throws IllegalArgumentException if algorithm or key length is invalid.
+     */
+    public IpSecAlgorithm(@NonNull @AlgorithmName String algorithm, @NonNull byte[] key) {
+        this(algorithm, key, 0);
+    }
+
+    /**
+     * Creates an IpSecAlgorithm of one of the supported types. Supported algorithm names are
+     * defined as constants in this class.
+     *
+     * <p>This constructor only supports algorithms that use a truncation length. i.e.
+     * Authentication and Authenticated Encryption algorithms.
+     *
+     * @param algorithm name of the algorithm.
+     * @param key key padded to a multiple of 8 bits.
+     * @param truncLenBits number of bits of output hash to use.
+     * @throws IllegalArgumentException if algorithm, key length or truncation length is invalid.
+     */
+    public IpSecAlgorithm(
+            @NonNull @AlgorithmName String algorithm, @NonNull byte[] key, int truncLenBits) {
+        mName = algorithm;
+        mKey = key.clone();
+        mTruncLenBits = truncLenBits;
+        checkValidOrThrow(mName, mKey.length * 8, mTruncLenBits);
+    }
+
+    /** Get the algorithm name */
+    @NonNull
+    public String getName() {
+        return mName;
+    }
+
+    /** Get the key for this algorithm */
+    @NonNull
+    public byte[] getKey() {
+        return mKey.clone();
+    }
+
+    /** Get the truncation length of this algorithm, in bits */
+    public int getTruncationLengthBits() {
+        return mTruncLenBits;
+    }
+
+    /** Parcelable Implementation */
+    public int describeContents() {
+        return 0;
+    }
+
+    /** Write to parcel */
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeString(mName);
+        out.writeByteArray(mKey);
+        out.writeInt(mTruncLenBits);
+    }
+
+    /** Parcelable Creator */
+    public static final @android.annotation.NonNull Parcelable.Creator<IpSecAlgorithm> CREATOR =
+            new Parcelable.Creator<IpSecAlgorithm>() {
+                public IpSecAlgorithm createFromParcel(Parcel in) {
+                    final String name = in.readString();
+                    final byte[] key = in.createByteArray();
+                    final int truncLenBits = in.readInt();
+
+                    return new IpSecAlgorithm(name, key, truncLenBits);
+                }
+
+                public IpSecAlgorithm[] newArray(int size) {
+                    return new IpSecAlgorithm[size];
+                }
+            };
+
+    /**
+     * Returns supported IPsec algorithms for the current device.
+     *
+     * <p>Some algorithms may not be supported on old devices. Callers MUST check if an algorithm is
+     * supported before using it.
+     */
+    @NonNull
+    public static Set<String> getSupportedAlgorithms() {
+        return ENABLED_ALGOS;
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public static Set<String> loadAlgos(Resources systemResources) {
+        final Set<String> enabledAlgos = new HashSet<>();
+
+        // Load and validate the optional algorithm resource. Undefined or duplicate algorithms in
+        // the resource are not allowed.
+        final String[] resourceAlgos = systemResources.getStringArray(
+                android.R.array.config_optionalIpSecAlgorithms);
+        for (String str : resourceAlgos) {
+            if (!ALGO_TO_REQUIRED_FIRST_SDK.containsKey(str) || !enabledAlgos.add(str)) {
+                // This error should be caught by CTS and never be thrown to API callers
+                throw new IllegalArgumentException("Invalid or repeated algorithm " + str);
+            }
+        }
+
+        for (Entry<String, Integer> entry : ALGO_TO_REQUIRED_FIRST_SDK.entrySet()) {
+            if (Build.VERSION.DEVICE_INITIAL_SDK_INT >= entry.getValue()) {
+                enabledAlgos.add(entry.getKey());
+            }
+        }
+
+        return enabledAlgos;
+    }
+
+    private static void checkValidOrThrow(String name, int keyLen, int truncLen) {
+        final boolean isValidLen;
+        final boolean isValidTruncLen;
+
+        if (!getSupportedAlgorithms().contains(name)) {
+            throw new IllegalArgumentException("Unsupported algorithm: " + name);
+        }
+
+        switch (name) {
+            case CRYPT_AES_CBC:
+                isValidLen = keyLen == 128 || keyLen == 192 || keyLen == 256;
+                isValidTruncLen = true;
+                break;
+            case CRYPT_AES_CTR:
+                // The keying material for AES-CTR is a key plus a 32-bit salt
+                isValidLen = keyLen == 128 + 32 || keyLen == 192 + 32 || keyLen == 256 + 32;
+                isValidTruncLen = true;
+                break;
+            case AUTH_HMAC_MD5:
+                isValidLen = keyLen == 128;
+                isValidTruncLen = truncLen >= 96 && truncLen <= 128;
+                break;
+            case AUTH_HMAC_SHA1:
+                isValidLen = keyLen == 160;
+                isValidTruncLen = truncLen >= 96 && truncLen <= 160;
+                break;
+            case AUTH_HMAC_SHA256:
+                isValidLen = keyLen == 256;
+                isValidTruncLen = truncLen >= 96 && truncLen <= 256;
+                break;
+            case AUTH_HMAC_SHA384:
+                isValidLen = keyLen == 384;
+                isValidTruncLen = truncLen >= 192 && truncLen <= 384;
+                break;
+            case AUTH_HMAC_SHA512:
+                isValidLen = keyLen == 512;
+                isValidTruncLen = truncLen >= 256 && truncLen <= 512;
+                break;
+            case AUTH_AES_XCBC:
+                isValidLen = keyLen == 128;
+                isValidTruncLen = truncLen == 96;
+                break;
+            case AUTH_AES_CMAC:
+                isValidLen = keyLen == 128;
+                isValidTruncLen = truncLen == 96;
+                break;
+            case AUTH_CRYPT_AES_GCM:
+                // The keying material for GCM is a key plus a 32-bit salt
+                isValidLen = keyLen == 128 + 32 || keyLen == 192 + 32 || keyLen == 256 + 32;
+                isValidTruncLen = truncLen == 64 || truncLen == 96 || truncLen == 128;
+                break;
+            case AUTH_CRYPT_CHACHA20_POLY1305:
+                // The keying material for ChaCha20Poly1305 is a key plus a 32-bit salt
+                isValidLen = keyLen == 256 + 32;
+                isValidTruncLen = truncLen == 128;
+                break;
+            default:
+                // Should never hit here.
+                throw new IllegalArgumentException("Couldn't find an algorithm: " + name);
+        }
+
+        if (!isValidLen) {
+            throw new IllegalArgumentException("Invalid key material keyLength: " + keyLen);
+        }
+        if (!isValidTruncLen) {
+            throw new IllegalArgumentException("Invalid truncation keyLength: " + truncLen);
+        }
+    }
+
+    /** @hide */
+    public boolean isAuthentication() {
+        switch (getName()) {
+            // Fallthrough
+            case AUTH_HMAC_MD5:
+            case AUTH_HMAC_SHA1:
+            case AUTH_HMAC_SHA256:
+            case AUTH_HMAC_SHA384:
+            case AUTH_HMAC_SHA512:
+            case AUTH_AES_XCBC:
+            case AUTH_AES_CMAC:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    /** @hide */
+    public boolean isEncryption() {
+        switch (getName()) {
+            case CRYPT_AES_CBC: // fallthrough
+            case CRYPT_AES_CTR:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    /** @hide */
+    public boolean isAead() {
+        switch (getName()) {
+            case AUTH_CRYPT_AES_GCM: // fallthrough
+            case AUTH_CRYPT_CHACHA20_POLY1305:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+        return new StringBuilder()
+                .append("{mName=")
+                .append(mName)
+                .append(", mTruncLenBits=")
+                .append(mTruncLenBits)
+                .append("}")
+                .toString();
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public static boolean equals(IpSecAlgorithm lhs, IpSecAlgorithm rhs) {
+        if (lhs == null || rhs == null) return (lhs == rhs);
+        return (lhs.mName.equals(rhs.mName)
+                && Arrays.equals(lhs.mKey, rhs.mKey)
+                && lhs.mTruncLenBits == rhs.mTruncLenBits);
+    }
+};
diff --git a/framework-t/src/android/net/IpSecConfig.aidl b/framework-t/src/android/net/IpSecConfig.aidl
new file mode 100644
index 0000000..eaefca7
--- /dev/null
+++ b/framework-t/src/android/net/IpSecConfig.aidl
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2017 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;
+
+/** @hide */
+parcelable IpSecConfig;
diff --git a/framework-t/src/android/net/IpSecConfig.java b/framework-t/src/android/net/IpSecConfig.java
new file mode 100644
index 0000000..03bb187
--- /dev/null
+++ b/framework-t/src/android/net/IpSecConfig.java
@@ -0,0 +1,358 @@
+/*
+ * Copyright (C) 2017 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.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * This class encapsulates all the configuration parameters needed to create IPsec transforms and
+ * policies.
+ *
+ * @hide
+ */
+public final class IpSecConfig implements Parcelable {
+    private static final String TAG = "IpSecConfig";
+
+    // MODE_TRANSPORT or MODE_TUNNEL
+    private int mMode = IpSecTransform.MODE_TRANSPORT;
+
+    // Preventing this from being null simplifies Java->Native binder
+    private String mSourceAddress = "";
+
+    // Preventing this from being null simplifies Java->Native binder
+    private String mDestinationAddress = "";
+
+    // The underlying Network that represents the "gateway" Network
+    // for outbound packets. It may also be used to select packets.
+    private Network mNetwork;
+
+    // Minimum requirements for identifying a transform
+    // SPI identifying the IPsec SA in packet processing
+    // and a destination IP address
+    private int mSpiResourceId = IpSecManager.INVALID_RESOURCE_ID;
+
+    // Encryption Algorithm
+    private IpSecAlgorithm mEncryption;
+
+    // Authentication Algorithm
+    private IpSecAlgorithm mAuthentication;
+
+    // Authenticated Encryption Algorithm
+    private IpSecAlgorithm mAuthenticatedEncryption;
+
+    // For tunnel mode IPv4 UDP Encapsulation
+    // IpSecTransform#ENCAP_ESP_*, such as ENCAP_ESP_OVER_UDP_IKE
+    private int mEncapType = IpSecTransform.ENCAP_NONE;
+    private int mEncapSocketResourceId = IpSecManager.INVALID_RESOURCE_ID;
+    private int mEncapRemotePort;
+
+    // An interval, in seconds between the NattKeepalive packets
+    private int mNattKeepaliveInterval;
+
+    // XFRM mark and mask; defaults to 0 (no mark/mask)
+    private int mMarkValue;
+    private int mMarkMask;
+
+    // XFRM interface id
+    private int mXfrmInterfaceId;
+
+    /** Set the mode for this IPsec transform */
+    public void setMode(int mode) {
+        mMode = mode;
+    }
+
+    /** Set the source IP addres for this IPsec transform */
+    public void setSourceAddress(String sourceAddress) {
+        mSourceAddress = sourceAddress;
+    }
+
+    /** Set the destination IP address for this IPsec transform */
+    public void setDestinationAddress(String destinationAddress) {
+        mDestinationAddress = destinationAddress;
+    }
+
+    /** Set the SPI by resource ID */
+    public void setSpiResourceId(int resourceId) {
+        mSpiResourceId = resourceId;
+    }
+
+    /** Set the encryption algorithm */
+    public void setEncryption(IpSecAlgorithm encryption) {
+        mEncryption = encryption;
+    }
+
+    /** Set the authentication algorithm */
+    public void setAuthentication(IpSecAlgorithm authentication) {
+        mAuthentication = authentication;
+    }
+
+    /** Set the authenticated encryption algorithm */
+    public void setAuthenticatedEncryption(IpSecAlgorithm authenticatedEncryption) {
+        mAuthenticatedEncryption = authenticatedEncryption;
+    }
+
+    /** Set the underlying network that will carry traffic for this transform */
+    public void setNetwork(Network network) {
+        mNetwork = network;
+    }
+
+    public void setEncapType(int encapType) {
+        mEncapType = encapType;
+    }
+
+    public void setEncapSocketResourceId(int resourceId) {
+        mEncapSocketResourceId = resourceId;
+    }
+
+    public void setEncapRemotePort(int port) {
+        mEncapRemotePort = port;
+    }
+
+    public void setNattKeepaliveInterval(int interval) {
+        mNattKeepaliveInterval = interval;
+    }
+
+    /**
+     * Sets the mark value
+     *
+     * <p>Internal (System server) use only. Marks passed in by users will be overwritten or
+     * ignored.
+     */
+    public void setMarkValue(int mark) {
+        mMarkValue = mark;
+    }
+
+    /**
+     * Sets the mark mask
+     *
+     * <p>Internal (System server) use only. Marks passed in by users will be overwritten or
+     * ignored.
+     */
+    public void setMarkMask(int mask) {
+        mMarkMask = mask;
+    }
+
+    public void setXfrmInterfaceId(int xfrmInterfaceId) {
+        mXfrmInterfaceId = xfrmInterfaceId;
+    }
+
+    // Transport or Tunnel
+    public int getMode() {
+        return mMode;
+    }
+
+    public String getSourceAddress() {
+        return mSourceAddress;
+    }
+
+    public int getSpiResourceId() {
+        return mSpiResourceId;
+    }
+
+    public String getDestinationAddress() {
+        return mDestinationAddress;
+    }
+
+    public IpSecAlgorithm getEncryption() {
+        return mEncryption;
+    }
+
+    public IpSecAlgorithm getAuthentication() {
+        return mAuthentication;
+    }
+
+    public IpSecAlgorithm getAuthenticatedEncryption() {
+        return mAuthenticatedEncryption;
+    }
+
+    public Network getNetwork() {
+        return mNetwork;
+    }
+
+    public int getEncapType() {
+        return mEncapType;
+    }
+
+    public int getEncapSocketResourceId() {
+        return mEncapSocketResourceId;
+    }
+
+    public int getEncapRemotePort() {
+        return mEncapRemotePort;
+    }
+
+    public int getNattKeepaliveInterval() {
+        return mNattKeepaliveInterval;
+    }
+
+    public int getMarkValue() {
+        return mMarkValue;
+    }
+
+    public int getMarkMask() {
+        return mMarkMask;
+    }
+
+    public int getXfrmInterfaceId() {
+        return mXfrmInterfaceId;
+    }
+
+    // Parcelable Methods
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeInt(mMode);
+        out.writeString(mSourceAddress);
+        out.writeString(mDestinationAddress);
+        out.writeParcelable(mNetwork, flags);
+        out.writeInt(mSpiResourceId);
+        out.writeParcelable(mEncryption, flags);
+        out.writeParcelable(mAuthentication, flags);
+        out.writeParcelable(mAuthenticatedEncryption, flags);
+        out.writeInt(mEncapType);
+        out.writeInt(mEncapSocketResourceId);
+        out.writeInt(mEncapRemotePort);
+        out.writeInt(mNattKeepaliveInterval);
+        out.writeInt(mMarkValue);
+        out.writeInt(mMarkMask);
+        out.writeInt(mXfrmInterfaceId);
+    }
+
+    @VisibleForTesting
+    public IpSecConfig() {}
+
+    /** Copy constructor */
+    @VisibleForTesting
+    public IpSecConfig(IpSecConfig c) {
+        mMode = c.mMode;
+        mSourceAddress = c.mSourceAddress;
+        mDestinationAddress = c.mDestinationAddress;
+        mNetwork = c.mNetwork;
+        mSpiResourceId = c.mSpiResourceId;
+        mEncryption = c.mEncryption;
+        mAuthentication = c.mAuthentication;
+        mAuthenticatedEncryption = c.mAuthenticatedEncryption;
+        mEncapType = c.mEncapType;
+        mEncapSocketResourceId = c.mEncapSocketResourceId;
+        mEncapRemotePort = c.mEncapRemotePort;
+        mNattKeepaliveInterval = c.mNattKeepaliveInterval;
+        mMarkValue = c.mMarkValue;
+        mMarkMask = c.mMarkMask;
+        mXfrmInterfaceId = c.mXfrmInterfaceId;
+    }
+
+    private IpSecConfig(Parcel in) {
+        mMode = in.readInt();
+        mSourceAddress = in.readString();
+        mDestinationAddress = in.readString();
+        mNetwork = (Network) in.readParcelable(Network.class.getClassLoader(), android.net.Network.class);
+        mSpiResourceId = in.readInt();
+        mEncryption =
+                (IpSecAlgorithm) in.readParcelable(IpSecAlgorithm.class.getClassLoader(), android.net.IpSecAlgorithm.class);
+        mAuthentication =
+                (IpSecAlgorithm) in.readParcelable(IpSecAlgorithm.class.getClassLoader(), android.net.IpSecAlgorithm.class);
+        mAuthenticatedEncryption =
+                (IpSecAlgorithm) in.readParcelable(IpSecAlgorithm.class.getClassLoader(), android.net.IpSecAlgorithm.class);
+        mEncapType = in.readInt();
+        mEncapSocketResourceId = in.readInt();
+        mEncapRemotePort = in.readInt();
+        mNattKeepaliveInterval = in.readInt();
+        mMarkValue = in.readInt();
+        mMarkMask = in.readInt();
+        mXfrmInterfaceId = in.readInt();
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder strBuilder = new StringBuilder();
+        strBuilder
+                .append("{mMode=")
+                .append(mMode == IpSecTransform.MODE_TUNNEL ? "TUNNEL" : "TRANSPORT")
+                .append(", mSourceAddress=")
+                .append(mSourceAddress)
+                .append(", mDestinationAddress=")
+                .append(mDestinationAddress)
+                .append(", mNetwork=")
+                .append(mNetwork)
+                .append(", mEncapType=")
+                .append(mEncapType)
+                .append(", mEncapSocketResourceId=")
+                .append(mEncapSocketResourceId)
+                .append(", mEncapRemotePort=")
+                .append(mEncapRemotePort)
+                .append(", mNattKeepaliveInterval=")
+                .append(mNattKeepaliveInterval)
+                .append("{mSpiResourceId=")
+                .append(mSpiResourceId)
+                .append(", mEncryption=")
+                .append(mEncryption)
+                .append(", mAuthentication=")
+                .append(mAuthentication)
+                .append(", mAuthenticatedEncryption=")
+                .append(mAuthenticatedEncryption)
+                .append(", mMarkValue=")
+                .append(mMarkValue)
+                .append(", mMarkMask=")
+                .append(mMarkMask)
+                .append(", mXfrmInterfaceId=")
+                .append(mXfrmInterfaceId)
+                .append("}");
+
+        return strBuilder.toString();
+    }
+
+    public static final @android.annotation.NonNull Parcelable.Creator<IpSecConfig> CREATOR =
+            new Parcelable.Creator<IpSecConfig>() {
+                public IpSecConfig createFromParcel(Parcel in) {
+                    return new IpSecConfig(in);
+                }
+
+                public IpSecConfig[] newArray(int size) {
+                    return new IpSecConfig[size];
+                }
+            };
+
+    @Override
+    public boolean equals(@Nullable Object other) {
+        if (!(other instanceof IpSecConfig)) return false;
+        final IpSecConfig rhs = (IpSecConfig) other;
+        return (mMode == rhs.mMode
+                && mSourceAddress.equals(rhs.mSourceAddress)
+                && mDestinationAddress.equals(rhs.mDestinationAddress)
+                && ((mNetwork != null && mNetwork.equals(rhs.mNetwork))
+                        || (mNetwork == rhs.mNetwork))
+                && mEncapType == rhs.mEncapType
+                && mEncapSocketResourceId == rhs.mEncapSocketResourceId
+                && mEncapRemotePort == rhs.mEncapRemotePort
+                && mNattKeepaliveInterval == rhs.mNattKeepaliveInterval
+                && mSpiResourceId == rhs.mSpiResourceId
+                && IpSecAlgorithm.equals(mEncryption, rhs.mEncryption)
+                && IpSecAlgorithm.equals(mAuthenticatedEncryption, rhs.mAuthenticatedEncryption)
+                && IpSecAlgorithm.equals(mAuthentication, rhs.mAuthentication)
+                && mMarkValue == rhs.mMarkValue
+                && mMarkMask == rhs.mMarkMask
+                && mXfrmInterfaceId == rhs.mXfrmInterfaceId);
+    }
+}
diff --git a/framework-t/src/android/net/IpSecManager.java b/framework-t/src/android/net/IpSecManager.java
new file mode 100644
index 0000000..9cb0947
--- /dev/null
+++ b/framework-t/src/android/net/IpSecManager.java
@@ -0,0 +1,1065 @@
+/*
+ * Copyright (C) 2017 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.RequiresFeature;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.annotation.SystemService;
+import android.annotation.TestApi;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.system.ErrnoException;
+import android.system.OsConstants;
+import android.util.AndroidException;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import dalvik.system.CloseGuard;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.util.Objects;
+
+/**
+ * This class contains methods for managing IPsec sessions. Once configured, the kernel will apply
+ * confidentiality (encryption) and integrity (authentication) to IP traffic.
+ *
+ * <p>Note that not all aspects of IPsec are permitted by this API. Applications may create
+ * transport mode security associations and apply them to individual sockets. Applications looking
+ * to create an IPsec VPN should use {@link VpnManager} and {@link Ikev2VpnProfile}.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc4301">RFC 4301, Security Architecture for the
+ *     Internet Protocol</a>
+ */
+@SystemService(Context.IPSEC_SERVICE)
+public class IpSecManager {
+    private static final String TAG = "IpSecManager";
+
+    /**
+     * Used when applying a transform to direct traffic through an {@link IpSecTransform}
+     * towards the host.
+     *
+     * <p>See {@link #applyTransportModeTransform(Socket, int, IpSecTransform)}.
+     */
+    public static final int DIRECTION_IN = 0;
+
+    /**
+     * Used when applying a transform to direct traffic through an {@link IpSecTransform}
+     * away from the host.
+     *
+     * <p>See {@link #applyTransportModeTransform(Socket, int, IpSecTransform)}.
+     */
+    public static final int DIRECTION_OUT = 1;
+
+    /**
+     * Used when applying a transform to direct traffic through an {@link IpSecTransform} for
+     * forwarding between interfaces.
+     *
+     * <p>See {@link #applyTransportModeTransform(Socket, int, IpSecTransform)}.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int DIRECTION_FWD = 2;
+
+    /** @hide */
+    @IntDef(value = {DIRECTION_IN, DIRECTION_OUT})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface PolicyDirection {}
+
+    /**
+     * The Security Parameter Index (SPI) 0 indicates an unknown or invalid index.
+     *
+     * <p>No IPsec packet may contain an SPI of 0.
+     *
+     * @hide
+     */
+    @TestApi public static final int INVALID_SECURITY_PARAMETER_INDEX = 0;
+
+    /** @hide */
+    public interface Status {
+        int OK = 0;
+        int RESOURCE_UNAVAILABLE = 1;
+        int SPI_UNAVAILABLE = 2;
+    }
+
+    /** @hide */
+    public static final int INVALID_RESOURCE_ID = -1;
+
+    /**
+     * Thrown to indicate that a requested SPI is in use.
+     *
+     * <p>The combination of remote {@code InetAddress} and SPI must be unique across all apps on
+     * one device. If this error is encountered, a new SPI is required before a transform may be
+     * created. This error can be avoided by calling {@link
+     * IpSecManager#allocateSecurityParameterIndex}.
+     */
+    public static final class SpiUnavailableException extends AndroidException {
+        private final int mSpi;
+
+        /**
+         * Construct an exception indicating that a transform with the given SPI is already in use
+         * or otherwise unavailable.
+         *
+         * @param msg description indicating the colliding SPI
+         * @param spi the SPI that could not be used due to a collision
+         */
+        SpiUnavailableException(String msg, int spi) {
+            super(msg + " (spi: " + spi + ")");
+            mSpi = spi;
+        }
+
+        /** Get the SPI that caused a collision. */
+        public int getSpi() {
+            return mSpi;
+        }
+    }
+
+    /**
+     * Thrown to indicate that an IPsec resource is unavailable.
+     *
+     * <p>This could apply to resources such as sockets, {@link SecurityParameterIndex}, {@link
+     * IpSecTransform}, or other system resources. If this exception is thrown, users should release
+     * allocated objects of the type requested.
+     */
+    public static final class ResourceUnavailableException extends AndroidException {
+
+        ResourceUnavailableException(String msg) {
+            super(msg);
+        }
+    }
+
+    private final Context mContext;
+    private final IIpSecService mService;
+
+    /**
+     * This class represents a reserved SPI.
+     *
+     * <p>Objects of this type are used to track reserved security parameter indices. They can be
+     * obtained by calling {@link IpSecManager#allocateSecurityParameterIndex} and must be released
+     * by calling {@link #close()} when they are no longer needed.
+     */
+    public static final class SecurityParameterIndex implements AutoCloseable {
+        private final IIpSecService mService;
+        private final InetAddress mDestinationAddress;
+        private final CloseGuard mCloseGuard = CloseGuard.get();
+        private int mSpi = INVALID_SECURITY_PARAMETER_INDEX;
+        private int mResourceId = INVALID_RESOURCE_ID;
+
+        /** Get the underlying SPI held by this object. */
+        public int getSpi() {
+            return mSpi;
+        }
+
+        /**
+         * Release an SPI that was previously reserved.
+         *
+         * <p>Release an SPI for use by other users in the system. If a SecurityParameterIndex is
+         * applied to an IpSecTransform, it will become unusable for future transforms but should
+         * still be closed to ensure system resources are released.
+         */
+        @Override
+        public void close() {
+            try {
+                mService.releaseSecurityParameterIndex(mResourceId);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (Exception e) {
+                // On close we swallow all random exceptions since failure to close is not
+                // actionable by the user.
+                Log.e(TAG, "Failed to close " + this + ", Exception=" + e);
+            } finally {
+                mResourceId = INVALID_RESOURCE_ID;
+                mCloseGuard.close();
+            }
+        }
+
+        /** Check that the SPI was closed properly. */
+        @Override
+        protected void finalize() throws Throwable {
+            if (mCloseGuard != null) {
+                mCloseGuard.warnIfOpen();
+            }
+
+            close();
+        }
+
+        private SecurityParameterIndex(
+                @NonNull IIpSecService service, InetAddress destinationAddress, int spi)
+                throws ResourceUnavailableException, SpiUnavailableException {
+            mService = service;
+            mDestinationAddress = destinationAddress;
+            try {
+                IpSecSpiResponse result =
+                        mService.allocateSecurityParameterIndex(
+                                destinationAddress.getHostAddress(), spi, new Binder());
+
+                if (result == null) {
+                    throw new NullPointerException("Received null response from IpSecService");
+                }
+
+                int status = result.status;
+                switch (status) {
+                    case Status.OK:
+                        break;
+                    case Status.RESOURCE_UNAVAILABLE:
+                        throw new ResourceUnavailableException(
+                                "No more SPIs may be allocated by this requester.");
+                    case Status.SPI_UNAVAILABLE:
+                        throw new SpiUnavailableException("Requested SPI is unavailable", spi);
+                    default:
+                        throw new RuntimeException(
+                                "Unknown status returned by IpSecService: " + status);
+                }
+                mSpi = result.spi;
+                mResourceId = result.resourceId;
+
+                if (mSpi == INVALID_SECURITY_PARAMETER_INDEX) {
+                    throw new RuntimeException("Invalid SPI returned by IpSecService: " + status);
+                }
+
+                if (mResourceId == INVALID_RESOURCE_ID) {
+                    throw new RuntimeException(
+                            "Invalid Resource ID returned by IpSecService: " + status);
+                }
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+            mCloseGuard.open("open");
+        }
+
+        /** @hide */
+        @VisibleForTesting
+        public int getResourceId() {
+            return mResourceId;
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                .append("SecurityParameterIndex{spi=")
+                .append(mSpi)
+                .append(",resourceId=")
+                .append(mResourceId)
+                .append("}")
+                .toString();
+        }
+    }
+
+    /**
+     * Reserve a random SPI for traffic bound to or from the specified destination address.
+     *
+     * <p>If successful, this SPI is guaranteed available until released by a call to {@link
+     * SecurityParameterIndex#close()}.
+     *
+     * @param destinationAddress the destination address for traffic bearing the requested SPI.
+     *     For inbound traffic, the destination should be an address currently assigned on-device.
+     * @return the reserved SecurityParameterIndex
+     * @throws ResourceUnavailableException indicating that too many SPIs are
+     *     currently allocated for this user
+     */
+    @NonNull
+    public SecurityParameterIndex allocateSecurityParameterIndex(
+                @NonNull InetAddress destinationAddress) throws ResourceUnavailableException {
+        try {
+            return new SecurityParameterIndex(
+                    mService,
+                    destinationAddress,
+                    IpSecManager.INVALID_SECURITY_PARAMETER_INDEX);
+        } catch (ServiceSpecificException e) {
+            throw rethrowUncheckedExceptionFromServiceSpecificException(e);
+        } catch (SpiUnavailableException unlikely) {
+            // Because this function allocates a totally random SPI, it really shouldn't ever
+            // fail to allocate an SPI; we simply need this because the exception is checked.
+            throw new ResourceUnavailableException("No SPIs available");
+        }
+    }
+
+    /**
+     * Reserve the requested SPI for traffic bound to or from the specified destination address.
+     *
+     * <p>If successful, this SPI is guaranteed available until released by a call to {@link
+     * SecurityParameterIndex#close()}.
+     *
+     * @param destinationAddress the destination address for traffic bearing the requested SPI.
+     *     For inbound traffic, the destination should be an address currently assigned on-device.
+     * @param requestedSpi the requested SPI. The range 1-255 is reserved and may not be used. See
+     *     RFC 4303 Section 2.1.
+     * @return the reserved SecurityParameterIndex
+     * @throws ResourceUnavailableException indicating that too many SPIs are
+     *     currently allocated for this user
+     * @throws SpiUnavailableException indicating that the requested SPI could not be
+     *     reserved
+     */
+    @NonNull
+    public SecurityParameterIndex allocateSecurityParameterIndex(
+            @NonNull InetAddress destinationAddress, int requestedSpi)
+            throws SpiUnavailableException, ResourceUnavailableException {
+        if (requestedSpi == IpSecManager.INVALID_SECURITY_PARAMETER_INDEX) {
+            throw new IllegalArgumentException("Requested SPI must be a valid (non-zero) SPI");
+        }
+        try {
+            return new SecurityParameterIndex(mService, destinationAddress, requestedSpi);
+        } catch (ServiceSpecificException e) {
+            throw rethrowUncheckedExceptionFromServiceSpecificException(e);
+        }
+    }
+
+    /**
+     * Apply an IPsec transform to a stream socket.
+     *
+     * <p>This applies transport mode encapsulation to the given socket. Once applied, I/O on the
+     * socket will be encapsulated according to the parameters of the {@code IpSecTransform}. When
+     * the transform is removed from the socket by calling {@link #removeTransportModeTransforms},
+     * unprotected traffic can resume on that socket.
+     *
+     * <p>For security reasons, the destination address of any traffic on the socket must match the
+     * remote {@code InetAddress} of the {@code IpSecTransform}. Attempts to send traffic to any
+     * other IP address will result in an IOException. In addition, reads and writes on the socket
+     * will throw IOException if the user deactivates the transform (by calling {@link
+     * IpSecTransform#close()}) without calling {@link #removeTransportModeTransforms}.
+     *
+     * <p>Note that when applied to TCP sockets, calling {@link IpSecTransform#close()} on an
+     * applied transform before completion of graceful shutdown may result in the shutdown sequence
+     * failing to complete. As such, applications requiring graceful shutdown MUST close the socket
+     * prior to deactivating the applied transform. Socket closure may be performed asynchronously
+     * (in batches), so the returning of a close function does not guarantee shutdown of a socket.
+     * Setting an SO_LINGER timeout results in socket closure being performed synchronously, and is
+     * sufficient to ensure shutdown.
+     *
+     * Specifically, if the transform is deactivated (by calling {@link IpSecTransform#close()}),
+     * prior to the socket being closed, the standard [FIN - FIN/ACK - ACK], or the reset [RST]
+     * packets are dropped due to the lack of a valid Transform. Similarly, if a socket without the
+     * SO_LINGER option set is closed, the delayed/batched FIN packets may be dropped.
+     *
+     * <h4>Rekey Procedure</h4>
+     *
+     * <p>When applying a new tranform to a socket in the outbound direction, the previous transform
+     * will be removed and the new transform will take effect immediately, sending all traffic on
+     * the new transform; however, when applying a transform in the inbound direction, traffic
+     * on the old transform will continue to be decrypted and delivered until that transform is
+     * deallocated by calling {@link IpSecTransform#close()}. This overlap allows lossless rekey
+     * procedures where both transforms are valid until both endpoints are using the new transform
+     * and all in-flight packets have been received.
+     *
+     * @param socket a stream socket
+     * @param direction the direction in which the transform should be applied
+     * @param transform a transport mode {@code IpSecTransform}
+     * @throws IOException indicating that the transform could not be applied
+     */
+    public void applyTransportModeTransform(@NonNull Socket socket,
+            @PolicyDirection int direction, @NonNull IpSecTransform transform) throws IOException {
+        // Ensure creation of FD. See b/77548890 for more details.
+        socket.getSoLinger();
+
+        applyTransportModeTransform(socket.getFileDescriptor$(), direction, transform);
+    }
+
+    /**
+     * Apply an IPsec transform to a datagram socket.
+     *
+     * <p>This applies transport mode encapsulation to the given socket. Once applied, I/O on the
+     * socket will be encapsulated according to the parameters of the {@code IpSecTransform}. When
+     * the transform is removed from the socket by calling {@link #removeTransportModeTransforms},
+     * unprotected traffic can resume on that socket.
+     *
+     * <p>For security reasons, the destination address of any traffic on the socket must match the
+     * remote {@code InetAddress} of the {@code IpSecTransform}. Attempts to send traffic to any
+     * other IP address will result in an IOException. In addition, reads and writes on the socket
+     * will throw IOException if the user deactivates the transform (by calling {@link
+     * IpSecTransform#close()}) without calling {@link #removeTransportModeTransforms}.
+     *
+     * <h4>Rekey Procedure</h4>
+     *
+     * <p>When applying a new tranform to a socket in the outbound direction, the previous transform
+     * will be removed and the new transform will take effect immediately, sending all traffic on
+     * the new transform; however, when applying a transform in the inbound direction, traffic
+     * on the old transform will continue to be decrypted and delivered until that transform is
+     * deallocated by calling {@link IpSecTransform#close()}. This overlap allows lossless rekey
+     * procedures where both transforms are valid until both endpoints are using the new transform
+     * and all in-flight packets have been received.
+     *
+     * @param socket a datagram socket
+     * @param direction the direction in which the transform should be applied
+     * @param transform a transport mode {@code IpSecTransform}
+     * @throws IOException indicating that the transform could not be applied
+     */
+    public void applyTransportModeTransform(@NonNull DatagramSocket socket,
+            @PolicyDirection int direction, @NonNull IpSecTransform transform) throws IOException {
+        applyTransportModeTransform(socket.getFileDescriptor$(), direction, transform);
+    }
+
+    /**
+     * Apply an IPsec transform to a socket.
+     *
+     * <p>This applies transport mode encapsulation to the given socket. Once applied, I/O on the
+     * socket will be encapsulated according to the parameters of the {@code IpSecTransform}. When
+     * the transform is removed from the socket by calling {@link #removeTransportModeTransforms},
+     * unprotected traffic can resume on that socket.
+     *
+     * <p>For security reasons, the destination address of any traffic on the socket must match the
+     * remote {@code InetAddress} of the {@code IpSecTransform}. Attempts to send traffic to any
+     * other IP address will result in an IOException. In addition, reads and writes on the socket
+     * will throw IOException if the user deactivates the transform (by calling {@link
+     * IpSecTransform#close()}) without calling {@link #removeTransportModeTransforms}.
+     *
+     * <p>Note that when applied to TCP sockets, calling {@link IpSecTransform#close()} on an
+     * applied transform before completion of graceful shutdown may result in the shutdown sequence
+     * failing to complete. As such, applications requiring graceful shutdown MUST close the socket
+     * prior to deactivating the applied transform. Socket closure may be performed asynchronously
+     * (in batches), so the returning of a close function does not guarantee shutdown of a socket.
+     * Setting an SO_LINGER timeout results in socket closure being performed synchronously, and is
+     * sufficient to ensure shutdown.
+     *
+     * Specifically, if the transform is deactivated (by calling {@link IpSecTransform#close()}),
+     * prior to the socket being closed, the standard [FIN - FIN/ACK - ACK], or the reset [RST]
+     * packets are dropped due to the lack of a valid Transform. Similarly, if a socket without the
+     * SO_LINGER option set is closed, the delayed/batched FIN packets may be dropped.
+     *
+     * <h4>Rekey Procedure</h4>
+     *
+     * <p>When applying a new tranform to a socket in the outbound direction, the previous transform
+     * will be removed and the new transform will take effect immediately, sending all traffic on
+     * the new transform; however, when applying a transform in the inbound direction, traffic
+     * on the old transform will continue to be decrypted and delivered until that transform is
+     * deallocated by calling {@link IpSecTransform#close()}. This overlap allows lossless rekey
+     * procedures where both transforms are valid until both endpoints are using the new transform
+     * and all in-flight packets have been received.
+     *
+     * @param socket a socket file descriptor
+     * @param direction the direction in which the transform should be applied
+     * @param transform a transport mode {@code IpSecTransform}
+     * @throws IOException indicating that the transform could not be applied
+     */
+    public void applyTransportModeTransform(@NonNull FileDescriptor socket,
+            @PolicyDirection int direction, @NonNull IpSecTransform transform) throws IOException {
+        // We dup() the FileDescriptor here because if we don't, then the ParcelFileDescriptor()
+        // constructor takes control and closes the user's FD when we exit the method.
+        try (ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(socket)) {
+            mService.applyTransportModeTransform(pfd, direction, transform.getResourceId());
+        } catch (ServiceSpecificException e) {
+            throw rethrowCheckedExceptionFromServiceSpecificException(e);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove an IPsec transform from a stream socket.
+     *
+     * <p>Once removed, traffic on the socket will not be encrypted. Removing transforms from a
+     * socket allows the socket to be reused for communication in the clear.
+     *
+     * <p>If an {@code IpSecTransform} object applied to this socket was deallocated by calling
+     * {@link IpSecTransform#close()}, then communication on the socket will fail until this method
+     * is called.
+     *
+     * @param socket a socket that previously had a transform applied to it
+     * @throws IOException indicating that the transform could not be removed from the socket
+     */
+    public void removeTransportModeTransforms(@NonNull Socket socket) throws IOException {
+        // Ensure creation of FD. See b/77548890 for more details.
+        socket.getSoLinger();
+
+        removeTransportModeTransforms(socket.getFileDescriptor$());
+    }
+
+    /**
+     * Remove an IPsec transform from a datagram socket.
+     *
+     * <p>Once removed, traffic on the socket will not be encrypted. Removing transforms from a
+     * socket allows the socket to be reused for communication in the clear.
+     *
+     * <p>If an {@code IpSecTransform} object applied to this socket was deallocated by calling
+     * {@link IpSecTransform#close()}, then communication on the socket will fail until this method
+     * is called.
+     *
+     * @param socket a socket that previously had a transform applied to it
+     * @throws IOException indicating that the transform could not be removed from the socket
+     */
+    public void removeTransportModeTransforms(@NonNull DatagramSocket socket) throws IOException {
+        removeTransportModeTransforms(socket.getFileDescriptor$());
+    }
+
+    /**
+     * Remove an IPsec transform from a socket.
+     *
+     * <p>Once removed, traffic on the socket will not be encrypted. Removing transforms from a
+     * socket allows the socket to be reused for communication in the clear.
+     *
+     * <p>If an {@code IpSecTransform} object applied to this socket was deallocated by calling
+     * {@link IpSecTransform#close()}, then communication on the socket will fail until this method
+     * is called.
+     *
+     * @param socket a socket that previously had a transform applied to it
+     * @throws IOException indicating that the transform could not be removed from the socket
+     */
+    public void removeTransportModeTransforms(@NonNull FileDescriptor socket) throws IOException {
+        try (ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(socket)) {
+            mService.removeTransportModeTransforms(pfd);
+        } catch (ServiceSpecificException e) {
+            throw rethrowCheckedExceptionFromServiceSpecificException(e);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove a Tunnel Mode IPsec Transform from a {@link Network}. This must be used as part of
+     * cleanup if a tunneled Network experiences a change in default route. The Network will drop
+     * all traffic that cannot be routed to the Tunnel's outbound interface. If that interface is
+     * lost, all traffic will drop.
+     *
+     * <p>TODO: Update javadoc for tunnel mode APIs at the same time the APIs are re-worked.
+     *
+     * @param net a network that currently has transform applied to it.
+     * @param transform a Tunnel Mode IPsec Transform that has been previously applied to the given
+     *     network
+     * @hide
+     */
+    public void removeTunnelModeTransform(Network net, IpSecTransform transform) {}
+
+    /**
+     * This class provides access to a UDP encapsulation Socket.
+     *
+     * <p>{@code UdpEncapsulationSocket} wraps a system-provided datagram socket intended for IKEv2
+     * signalling and UDP encapsulated IPsec traffic. Instances can be obtained by calling {@link
+     * IpSecManager#openUdpEncapsulationSocket}. The provided socket cannot be re-bound by the
+     * caller. The caller should not close the {@code FileDescriptor} returned by {@link
+     * #getFileDescriptor}, but should use {@link #close} instead.
+     *
+     * <p>Allowing the user to close or unbind a UDP encapsulation socket could impact the traffic
+     * of the next user who binds to that port. To prevent this scenario, these sockets are held
+     * open by the system so that they may only be closed by calling {@link #close} or when the user
+     * process exits.
+     */
+    public static final class UdpEncapsulationSocket implements AutoCloseable {
+        private final ParcelFileDescriptor mPfd;
+        private final IIpSecService mService;
+        private int mResourceId = INVALID_RESOURCE_ID;
+        private final int mPort;
+        private final CloseGuard mCloseGuard = CloseGuard.get();
+
+        private UdpEncapsulationSocket(@NonNull IIpSecService service, int port)
+                throws ResourceUnavailableException, IOException {
+            mService = service;
+            try {
+                IpSecUdpEncapResponse result =
+                        mService.openUdpEncapsulationSocket(port, new Binder());
+                switch (result.status) {
+                    case Status.OK:
+                        break;
+                    case Status.RESOURCE_UNAVAILABLE:
+                        throw new ResourceUnavailableException(
+                                "No more Sockets may be allocated by this requester.");
+                    default:
+                        throw new RuntimeException(
+                                "Unknown status returned by IpSecService: " + result.status);
+                }
+                mResourceId = result.resourceId;
+                mPort = result.port;
+                mPfd = result.fileDescriptor;
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+            mCloseGuard.open("constructor");
+        }
+
+        /** Get the encapsulation socket's file descriptor. */
+        public FileDescriptor getFileDescriptor() {
+            if (mPfd == null) {
+                return null;
+            }
+            return mPfd.getFileDescriptor();
+        }
+
+        /** Get the bound port of the wrapped socket. */
+        public int getPort() {
+            return mPort;
+        }
+
+        /**
+         * Close this socket.
+         *
+         * <p>This closes the wrapped socket. Open encapsulation sockets count against a user's
+         * resource limits, and forgetting to close them eventually will result in {@link
+         * ResourceUnavailableException} being thrown.
+         */
+        @Override
+        public void close() throws IOException {
+            try {
+                mService.closeUdpEncapsulationSocket(mResourceId);
+                mResourceId = INVALID_RESOURCE_ID;
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (Exception e) {
+                // On close we swallow all random exceptions since failure to close is not
+                // actionable by the user.
+                Log.e(TAG, "Failed to close " + this + ", Exception=" + e);
+            } finally {
+                mResourceId = INVALID_RESOURCE_ID;
+                mCloseGuard.close();
+            }
+
+            try {
+                mPfd.close();
+            } catch (IOException e) {
+                Log.e(TAG, "Failed to close UDP Encapsulation Socket with Port= " + mPort);
+                throw e;
+            }
+        }
+
+        /** Check that the socket was closed properly. */
+        @Override
+        protected void finalize() throws Throwable {
+            if (mCloseGuard != null) {
+                mCloseGuard.warnIfOpen();
+            }
+            close();
+        }
+
+        /** @hide */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public int getResourceId() {
+            return mResourceId;
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                .append("UdpEncapsulationSocket{port=")
+                .append(mPort)
+                .append(",resourceId=")
+                .append(mResourceId)
+                .append("}")
+                .toString();
+        }
+    };
+
+    /**
+     * Open a socket for UDP encapsulation and bind to the given port.
+     *
+     * <p>See {@link UdpEncapsulationSocket} for the proper way to close the returned socket.
+     *
+     * @param port a local UDP port
+     * @return a socket that is bound to the given port
+     * @throws IOException indicating that the socket could not be opened or bound
+     * @throws ResourceUnavailableException indicating that too many encapsulation sockets are open
+     */
+    // Returning a socket in this fashion that has been created and bound by the system
+    // is the only safe way to ensure that a socket is both accessible to the user and
+    // safely usable for Encapsulation without allowing a user to possibly unbind from/close
+    // the port, which could potentially impact the traffic of the next user who binds to that
+    // socket.
+    @NonNull
+    public UdpEncapsulationSocket openUdpEncapsulationSocket(int port)
+            throws IOException, ResourceUnavailableException {
+        /*
+         * Most range checking is done in the service, but this version of the constructor expects
+         * a valid port number, and zero cannot be checked after being passed to the service.
+         */
+        if (port == 0) {
+            throw new IllegalArgumentException("Specified port must be a valid port number!");
+        }
+        try {
+            return new UdpEncapsulationSocket(mService, port);
+        } catch (ServiceSpecificException e) {
+            throw rethrowCheckedExceptionFromServiceSpecificException(e);
+        }
+    }
+
+    /**
+     * Open a socket for UDP encapsulation.
+     *
+     * <p>See {@link UdpEncapsulationSocket} for the proper way to close the returned socket.
+     *
+     * <p>The local port of the returned socket can be obtained by calling {@link
+     * UdpEncapsulationSocket#getPort()}.
+     *
+     * @return a socket that is bound to a local port
+     * @throws IOException indicating that the socket could not be opened or bound
+     * @throws ResourceUnavailableException indicating that too many encapsulation sockets are open
+     */
+    // Returning a socket in this fashion that has been created and bound by the system
+    // is the only safe way to ensure that a socket is both accessible to the user and
+    // safely usable for Encapsulation without allowing a user to possibly unbind from/close
+    // the port, which could potentially impact the traffic of the next user who binds to that
+    // socket.
+    @NonNull
+    public UdpEncapsulationSocket openUdpEncapsulationSocket()
+            throws IOException, ResourceUnavailableException {
+        try {
+            return new UdpEncapsulationSocket(mService, 0);
+        } catch (ServiceSpecificException e) {
+            throw rethrowCheckedExceptionFromServiceSpecificException(e);
+        }
+    }
+
+    /**
+     * This class represents an IpSecTunnelInterface
+     *
+     * <p>IpSecTunnelInterface objects track tunnel interfaces that serve as
+     * local endpoints for IPsec tunnels.
+     *
+     * <p>Creating an IpSecTunnelInterface creates a device to which IpSecTransforms may be
+     * applied to provide IPsec security to packets sent through the tunnel. While a tunnel
+     * cannot be used in standalone mode within Android, the higher layers may use the tunnel
+     * to create Network objects which are accessible to the Android system.
+     * @hide
+     */
+    @SystemApi
+    public static final class IpSecTunnelInterface implements AutoCloseable {
+        private final String mOpPackageName;
+        private final IIpSecService mService;
+        private final InetAddress mRemoteAddress;
+        private final InetAddress mLocalAddress;
+        private final Network mUnderlyingNetwork;
+        private final CloseGuard mCloseGuard = CloseGuard.get();
+        private String mInterfaceName;
+        private int mResourceId = INVALID_RESOURCE_ID;
+
+        /** Get the underlying SPI held by this object. */
+        @NonNull
+        public String getInterfaceName() {
+            return mInterfaceName;
+        }
+
+        /**
+         * Add an address to the IpSecTunnelInterface
+         *
+         * <p>Add an address which may be used as the local inner address for
+         * tunneled traffic.
+         *
+         * @param address the local address for traffic inside the tunnel
+         * @param prefixLen length of the InetAddress prefix
+         * @hide
+         */
+        @SystemApi
+        @RequiresFeature(PackageManager.FEATURE_IPSEC_TUNNELS)
+        @RequiresPermission(android.Manifest.permission.MANAGE_IPSEC_TUNNELS)
+        public void addAddress(@NonNull InetAddress address, int prefixLen) throws IOException {
+            try {
+                mService.addAddressToTunnelInterface(
+                        mResourceId, new LinkAddress(address, prefixLen), mOpPackageName);
+            } catch (ServiceSpecificException e) {
+                throw rethrowCheckedExceptionFromServiceSpecificException(e);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+
+        /**
+         * Remove an address from the IpSecTunnelInterface
+         *
+         * <p>Remove an address which was previously added to the IpSecTunnelInterface
+         *
+         * @param address to be removed
+         * @param prefixLen length of the InetAddress prefix
+         * @hide
+         */
+        @SystemApi
+        @RequiresFeature(PackageManager.FEATURE_IPSEC_TUNNELS)
+        @RequiresPermission(android.Manifest.permission.MANAGE_IPSEC_TUNNELS)
+        public void removeAddress(@NonNull InetAddress address, int prefixLen) throws IOException {
+            try {
+                mService.removeAddressFromTunnelInterface(
+                        mResourceId, new LinkAddress(address, prefixLen), mOpPackageName);
+            } catch (ServiceSpecificException e) {
+                throw rethrowCheckedExceptionFromServiceSpecificException(e);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+
+        /**
+         * Update the underlying network for this IpSecTunnelInterface.
+         *
+         * <p>This new underlying network will be used for all transforms applied AFTER this call is
+         * complete. Before new {@link IpSecTransform}(s) with matching addresses are applied to
+         * this tunnel interface, traffic will still use the old SA, and be routed on the old
+         * underlying network.
+         *
+         * <p>To migrate IPsec tunnel mode traffic, a caller should:
+         *
+         * <ol>
+         *   <li>Update the IpSecTunnelInterface’s underlying network.
+         *   <li>Apply {@link IpSecTransform}(s) with matching addresses to this
+         *       IpSecTunnelInterface.
+         * </ol>
+         *
+         * @param underlyingNetwork the new {@link Network} that will carry traffic for this tunnel.
+         *     This network MUST never be the network exposing this IpSecTunnelInterface, otherwise
+         *     this method will throw an {@link IllegalArgumentException}. If the
+         *     IpSecTunnelInterface is later added to this network, all outbound traffic will be
+         *     blackholed.
+         */
+        // TODO: b/169171001 Update the documentation when transform migration is supported.
+        // The purpose of making updating network and applying transforms separate is to leave open
+        // the possibility to support lossless migration procedures. To do that, Android platform
+        // will need to support multiple inbound tunnel mode transforms, just like it can support
+        // multiple transport mode transforms.
+        @RequiresFeature(PackageManager.FEATURE_IPSEC_TUNNELS)
+        @RequiresPermission(android.Manifest.permission.MANAGE_IPSEC_TUNNELS)
+        public void setUnderlyingNetwork(@NonNull Network underlyingNetwork) throws IOException {
+            try {
+                mService.setNetworkForTunnelInterface(
+                        mResourceId, underlyingNetwork, mOpPackageName);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+
+        private IpSecTunnelInterface(@NonNull Context ctx, @NonNull IIpSecService service,
+                @NonNull InetAddress localAddress, @NonNull InetAddress remoteAddress,
+                @NonNull Network underlyingNetwork)
+                throws ResourceUnavailableException, IOException {
+            mOpPackageName = ctx.getOpPackageName();
+            mService = service;
+            mLocalAddress = localAddress;
+            mRemoteAddress = remoteAddress;
+            mUnderlyingNetwork = underlyingNetwork;
+
+            try {
+                IpSecTunnelInterfaceResponse result =
+                        mService.createTunnelInterface(
+                                localAddress.getHostAddress(),
+                                remoteAddress.getHostAddress(),
+                                underlyingNetwork,
+                                new Binder(),
+                                mOpPackageName);
+                switch (result.status) {
+                    case Status.OK:
+                        break;
+                    case Status.RESOURCE_UNAVAILABLE:
+                        throw new ResourceUnavailableException(
+                                "No more tunnel interfaces may be allocated by this requester.");
+                    default:
+                        throw new RuntimeException(
+                                "Unknown status returned by IpSecService: " + result.status);
+                }
+                mResourceId = result.resourceId;
+                mInterfaceName = result.interfaceName;
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+            mCloseGuard.open("constructor");
+        }
+
+        /**
+         * Delete an IpSecTunnelInterface
+         *
+         * <p>Calling close will deallocate the IpSecTunnelInterface and all of its system
+         * resources. Any packets bound for this interface either inbound or outbound will
+         * all be lost.
+         */
+        @Override
+        public void close() {
+            try {
+                mService.deleteTunnelInterface(mResourceId, mOpPackageName);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (Exception e) {
+                // On close we swallow all random exceptions since failure to close is not
+                // actionable by the user.
+                Log.e(TAG, "Failed to close " + this + ", Exception=" + e);
+            } finally {
+                mResourceId = INVALID_RESOURCE_ID;
+                mCloseGuard.close();
+            }
+        }
+
+        /** Check that the Interface was closed properly. */
+        @Override
+        protected void finalize() throws Throwable {
+            if (mCloseGuard != null) {
+                mCloseGuard.warnIfOpen();
+            }
+            close();
+        }
+
+        /** @hide */
+        @VisibleForTesting
+        public int getResourceId() {
+            return mResourceId;
+        }
+
+        @NonNull
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                .append("IpSecTunnelInterface{ifname=")
+                .append(mInterfaceName)
+                .append(",resourceId=")
+                .append(mResourceId)
+                .append("}")
+                .toString();
+        }
+    }
+
+    /**
+     * Create a new IpSecTunnelInterface as a local endpoint for tunneled IPsec traffic.
+     *
+     * <p>An application that creates tunnels is responsible for cleaning up the tunnel when the
+     * underlying network goes away, and the onLost() callback is received.
+     *
+     * @param localAddress The local addres of the tunnel
+     * @param remoteAddress The local addres of the tunnel
+     * @param underlyingNetwork the {@link Network} that will carry traffic for this tunnel.
+     *        This network should almost certainly be a network such as WiFi with an L2 address.
+     * @return a new {@link IpSecManager#IpSecTunnelInterface} with the specified properties
+     * @throws IOException indicating that the socket could not be opened or bound
+     * @throws ResourceUnavailableException indicating that too many encapsulation sockets are open
+     * @hide
+     */
+    @SystemApi
+    @NonNull
+    @RequiresFeature(PackageManager.FEATURE_IPSEC_TUNNELS)
+    @RequiresPermission(android.Manifest.permission.MANAGE_IPSEC_TUNNELS)
+    public IpSecTunnelInterface createIpSecTunnelInterface(@NonNull InetAddress localAddress,
+            @NonNull InetAddress remoteAddress, @NonNull Network underlyingNetwork)
+            throws ResourceUnavailableException, IOException {
+        try {
+            return new IpSecTunnelInterface(
+                    mContext, mService, localAddress, remoteAddress, underlyingNetwork);
+        } catch (ServiceSpecificException e) {
+            throw rethrowCheckedExceptionFromServiceSpecificException(e);
+        }
+    }
+
+    /**
+     * Apply an active Tunnel Mode IPsec Transform to a {@link IpSecTunnelInterface}, which will
+     * tunnel all traffic for the given direction through the underlying network's interface with
+     * IPsec (applies an outer IP header and IPsec Header to all traffic, and expects an additional
+     * IP header and IPsec Header on all inbound traffic).
+     * <p>Applications should probably not use this API directly.
+     *
+     *
+     * @param tunnel The {@link IpSecManager#IpSecTunnelInterface} that will use the supplied
+     *        transform.
+     * @param direction the direction, {@link DIRECTION_OUT} or {@link #DIRECTION_IN} in which
+     *        the transform will be used.
+     * @param transform an {@link IpSecTransform} created in tunnel mode
+     * @throws IOException indicating that the transform could not be applied due to a lower
+     *         layer failure.
+     * @hide
+     */
+    @SystemApi
+    @RequiresFeature(PackageManager.FEATURE_IPSEC_TUNNELS)
+    @RequiresPermission(android.Manifest.permission.MANAGE_IPSEC_TUNNELS)
+    public void applyTunnelModeTransform(@NonNull IpSecTunnelInterface tunnel,
+            @PolicyDirection int direction, @NonNull IpSecTransform transform) throws IOException {
+        try {
+            mService.applyTunnelModeTransform(
+                    tunnel.getResourceId(), direction,
+                    transform.getResourceId(), mContext.getOpPackageName());
+        } catch (ServiceSpecificException e) {
+            throw rethrowCheckedExceptionFromServiceSpecificException(e);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * @hide
+     */
+    public IpSecTransformResponse createTransform(IpSecConfig config, IBinder binder,
+            String callingPackage) {
+        try {
+            return mService.createTransform(config, binder, callingPackage);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * @hide
+     */
+    public void deleteTransform(int resourceId) {
+        try {
+            mService.deleteTransform(resourceId);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Construct an instance of IpSecManager within an application context.
+     *
+     * @param context the application context for this manager
+     * @hide
+     */
+    public IpSecManager(Context ctx, IIpSecService service) {
+        mContext = ctx;
+        mService = Objects.requireNonNull(service, "missing service");
+    }
+
+    private static void maybeHandleServiceSpecificException(ServiceSpecificException sse) {
+        // OsConstants are late binding, so switch statements can't be used.
+        if (sse.errorCode == OsConstants.EINVAL) {
+            throw new IllegalArgumentException(sse);
+        } else if (sse.errorCode == OsConstants.EAGAIN) {
+            throw new IllegalStateException(sse);
+        } else if (sse.errorCode == OsConstants.EOPNOTSUPP
+                || sse.errorCode == OsConstants.EPROTONOSUPPORT) {
+            throw new UnsupportedOperationException(sse);
+        }
+    }
+
+    /**
+     * Convert an Errno SSE to the correct Unchecked exception type.
+     *
+     * This method never actually returns.
+     */
+    // package
+    static RuntimeException
+            rethrowUncheckedExceptionFromServiceSpecificException(ServiceSpecificException sse) {
+        maybeHandleServiceSpecificException(sse);
+        throw new RuntimeException(sse);
+    }
+
+    /**
+     * Convert an Errno SSE to the correct Checked or Unchecked exception type.
+     *
+     * This method may throw IOException, or it may throw an unchecked exception; it will never
+     * actually return.
+     */
+    // package
+    static IOException rethrowCheckedExceptionFromServiceSpecificException(
+            ServiceSpecificException sse) throws IOException {
+        // First see if this is an unchecked exception of a type we know.
+        // If so, then we prefer the unchecked (specific) type of exception.
+        maybeHandleServiceSpecificException(sse);
+        // If not, then all we can do is provide the SSE in the form of an IOException.
+        throw new ErrnoException(
+                "IpSec encountered errno=" + sse.errorCode, sse.errorCode).rethrowAsIOException();
+    }
+}
diff --git a/framework-t/src/android/net/IpSecSpiResponse.aidl b/framework-t/src/android/net/IpSecSpiResponse.aidl
new file mode 100644
index 0000000..6484a00
--- /dev/null
+++ b/framework-t/src/android/net/IpSecSpiResponse.aidl
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2017 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;
+
+/** @hide */
+parcelable IpSecSpiResponse;
diff --git a/framework-t/src/android/net/IpSecSpiResponse.java b/framework-t/src/android/net/IpSecSpiResponse.java
new file mode 100644
index 0000000..f99e570
--- /dev/null
+++ b/framework-t/src/android/net/IpSecSpiResponse.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2017 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.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * This class is used to return an SPI and corresponding status from the IpSecService to an
+ * IpSecManager.SecurityParameterIndex.
+ *
+ * @hide
+ */
+public final class IpSecSpiResponse implements Parcelable {
+    private static final String TAG = "IpSecSpiResponse";
+
+    public final int resourceId;
+    public final int status;
+    public final int spi;
+    // Parcelable Methods
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeInt(status);
+        out.writeInt(resourceId);
+        out.writeInt(spi);
+    }
+
+    public IpSecSpiResponse(int inStatus, int inResourceId, int inSpi) {
+        status = inStatus;
+        resourceId = inResourceId;
+        spi = inSpi;
+    }
+
+    public IpSecSpiResponse(int inStatus) {
+        if (inStatus == IpSecManager.Status.OK) {
+            throw new IllegalArgumentException("Valid status implies other args must be provided");
+        }
+        status = inStatus;
+        resourceId = IpSecManager.INVALID_RESOURCE_ID;
+        spi = IpSecManager.INVALID_SECURITY_PARAMETER_INDEX;
+    }
+
+    private IpSecSpiResponse(Parcel in) {
+        status = in.readInt();
+        resourceId = in.readInt();
+        spi = in.readInt();
+    }
+
+    public static final @android.annotation.NonNull Parcelable.Creator<IpSecSpiResponse> CREATOR =
+            new Parcelable.Creator<IpSecSpiResponse>() {
+                public IpSecSpiResponse createFromParcel(Parcel in) {
+                    return new IpSecSpiResponse(in);
+                }
+
+                public IpSecSpiResponse[] newArray(int size) {
+                    return new IpSecSpiResponse[size];
+                }
+            };
+}
diff --git a/framework-t/src/android/net/IpSecTransform.java b/framework-t/src/android/net/IpSecTransform.java
new file mode 100644
index 0000000..68ae5de
--- /dev/null
+++ b/framework-t/src/android/net/IpSecTransform.java
@@ -0,0 +1,405 @@
+/*
+ * Copyright (C) 2017 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.IpSecManager.INVALID_RESOURCE_ID;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresFeature;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Binder;
+import android.os.ServiceSpecificException;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import dalvik.system.CloseGuard;
+
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.InetAddress;
+import java.util.Objects;
+
+/**
+ * This class represents a transform, which roughly corresponds to an IPsec Security Association.
+ *
+ * <p>Transforms are created using {@link IpSecTransform.Builder}. Each {@code IpSecTransform}
+ * object encapsulates the properties and state of an IPsec security association. That includes,
+ * but is not limited to, algorithm choice, key material, and allocated system resources.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc4301">RFC 4301, Security Architecture for the
+ *     Internet Protocol</a>
+ */
+public final class IpSecTransform implements AutoCloseable {
+    private static final String TAG = "IpSecTransform";
+
+    /** @hide */
+    public static final int MODE_TRANSPORT = 0;
+
+    /** @hide */
+    public static final int MODE_TUNNEL = 1;
+
+    /** @hide */
+    public static final int ENCAP_NONE = 0;
+
+    /**
+     * IPsec traffic will be encapsulated within UDP, but with 8 zero-value bytes between the UDP
+     * header and payload. This prevents traffic from being interpreted as ESP or IKEv2.
+     *
+     * @hide
+     */
+    public static final int ENCAP_ESPINUDP_NON_IKE = 1;
+
+    /**
+     * IPsec traffic will be encapsulated within UDP as per
+     * <a href="https://tools.ietf.org/html/rfc3948">RFC 3498</a>.
+     *
+     * @hide
+     */
+    public static final int ENCAP_ESPINUDP = 2;
+
+    /** @hide */
+    @IntDef(value = {ENCAP_NONE, ENCAP_ESPINUDP, ENCAP_ESPINUDP_NON_IKE})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface EncapType {}
+
+    /** @hide */
+    @VisibleForTesting
+    public IpSecTransform(Context context, IpSecConfig config) {
+        mContext = context;
+        mConfig = new IpSecConfig(config);
+        mResourceId = INVALID_RESOURCE_ID;
+    }
+
+    private IpSecManager getIpSecManager(Context context) {
+        return context.getSystemService(IpSecManager.class);
+    }
+    /**
+     * Checks the result status and throws an appropriate exception if the status is not Status.OK.
+     */
+    private void checkResultStatus(int status)
+            throws IOException, IpSecManager.ResourceUnavailableException,
+                    IpSecManager.SpiUnavailableException {
+        switch (status) {
+            case IpSecManager.Status.OK:
+                return;
+                // TODO: Pass Error string back from bundle so that errors can be more specific
+            case IpSecManager.Status.RESOURCE_UNAVAILABLE:
+                throw new IpSecManager.ResourceUnavailableException(
+                        "Failed to allocate a new IpSecTransform");
+            case IpSecManager.Status.SPI_UNAVAILABLE:
+                Log.wtf(TAG, "Attempting to use an SPI that was somehow not reserved");
+                // Fall through
+            default:
+                throw new IllegalStateException(
+                        "Failed to Create a Transform with status code " + status);
+        }
+    }
+
+    private IpSecTransform activate()
+            throws IOException, IpSecManager.ResourceUnavailableException,
+                    IpSecManager.SpiUnavailableException {
+        synchronized (this) {
+            try {
+                IpSecTransformResponse result = getIpSecManager(mContext).createTransform(
+                        mConfig, new Binder(), mContext.getOpPackageName());
+                int status = result.status;
+                checkResultStatus(status);
+                mResourceId = result.resourceId;
+                Log.d(TAG, "Added Transform with Id " + mResourceId);
+                mCloseGuard.open("build");
+            } catch (ServiceSpecificException e) {
+                throw IpSecManager.rethrowUncheckedExceptionFromServiceSpecificException(e);
+            }
+        }
+
+        return this;
+    }
+
+    /**
+     * Standard equals.
+     */
+    public boolean equals(@Nullable Object other) {
+        if (this == other) return true;
+        if (!(other instanceof IpSecTransform)) return false;
+        final IpSecTransform rhs = (IpSecTransform) other;
+        return getConfig().equals(rhs.getConfig()) && mResourceId == rhs.mResourceId;
+    }
+
+    /**
+     * Deactivate this {@code IpSecTransform} and free allocated resources.
+     *
+     * <p>Deactivating a transform while it is still applied to a socket will result in errors on
+     * that socket. Make sure to remove transforms by calling {@link
+     * IpSecManager#removeTransportModeTransforms}. Note, removing an {@code IpSecTransform} from a
+     * socket will not deactivate it (because one transform may be applied to multiple sockets).
+     *
+     * <p>It is safe to call this method on a transform that has already been deactivated.
+     */
+    public void close() {
+        Log.d(TAG, "Removing Transform with Id " + mResourceId);
+
+        // Always safe to attempt cleanup
+        if (mResourceId == INVALID_RESOURCE_ID) {
+            mCloseGuard.close();
+            return;
+        }
+        try {
+            getIpSecManager(mContext).deleteTransform(mResourceId);
+        } catch (Exception e) {
+            // On close we swallow all random exceptions since failure to close is not
+            // actionable by the user.
+            Log.e(TAG, "Failed to close " + this + ", Exception=" + e);
+        } finally {
+            mResourceId = INVALID_RESOURCE_ID;
+            mCloseGuard.close();
+        }
+    }
+
+    /** Check that the transform was closed properly. */
+    @Override
+    protected void finalize() throws Throwable {
+        if (mCloseGuard != null) {
+            mCloseGuard.warnIfOpen();
+        }
+        close();
+    }
+
+    /* Package */
+    IpSecConfig getConfig() {
+        return mConfig;
+    }
+
+    private final IpSecConfig mConfig;
+    private int mResourceId;
+    private final Context mContext;
+    private final CloseGuard mCloseGuard = CloseGuard.get();
+
+    /** @hide */
+    @VisibleForTesting
+    public int getResourceId() {
+        return mResourceId;
+    }
+
+    /**
+     * A callback class to provide status information regarding a NAT-T keepalive session
+     *
+     * <p>Use this callback to receive status information regarding a NAT-T keepalive session
+     * by registering it when calling {@link #startNattKeepalive}.
+     *
+     * @hide
+     */
+    public static class NattKeepaliveCallback {
+        /** The specified {@code Network} is not connected. */
+        public static final int ERROR_INVALID_NETWORK = 1;
+        /** The hardware does not support this request. */
+        public static final int ERROR_HARDWARE_UNSUPPORTED = 2;
+        /** The hardware returned an error. */
+        public static final int ERROR_HARDWARE_ERROR = 3;
+
+        /** The requested keepalive was successfully started. */
+        public void onStarted() {}
+        /** The keepalive was successfully stopped. */
+        public void onStopped() {}
+        /** An error occurred. */
+        public void onError(int error) {}
+    }
+
+    /** This class is used to build {@link IpSecTransform} objects. */
+    public static class Builder {
+        private Context mContext;
+        private IpSecConfig mConfig;
+
+        /**
+         * Set the encryption algorithm.
+         *
+         * <p>Encryption is mutually exclusive with authenticated encryption.
+         *
+         * @param algo {@link IpSecAlgorithm} specifying the encryption to be applied.
+         */
+        @NonNull
+        public IpSecTransform.Builder setEncryption(@NonNull IpSecAlgorithm algo) {
+            // TODO: throw IllegalArgumentException if algo is not an encryption algorithm.
+            Objects.requireNonNull(algo);
+            mConfig.setEncryption(algo);
+            return this;
+        }
+
+        /**
+         * Set the authentication (integrity) algorithm.
+         *
+         * <p>Authentication is mutually exclusive with authenticated encryption.
+         *
+         * @param algo {@link IpSecAlgorithm} specifying the authentication to be applied.
+         */
+        @NonNull
+        public IpSecTransform.Builder setAuthentication(@NonNull IpSecAlgorithm algo) {
+            // TODO: throw IllegalArgumentException if algo is not an authentication algorithm.
+            Objects.requireNonNull(algo);
+            mConfig.setAuthentication(algo);
+            return this;
+        }
+
+        /**
+         * Set the authenticated encryption algorithm.
+         *
+         * <p>The Authenticated Encryption (AE) class of algorithms are also known as
+         * Authenticated Encryption with Associated Data (AEAD) algorithms, or Combined mode
+         * algorithms (as referred to in
+         * <a href="https://tools.ietf.org/html/rfc4301">RFC 4301</a>).
+         *
+         * <p>Authenticated encryption is mutually exclusive with encryption and authentication.
+         *
+         * @param algo {@link IpSecAlgorithm} specifying the authenticated encryption algorithm to
+         *     be applied.
+         */
+        @NonNull
+        public IpSecTransform.Builder setAuthenticatedEncryption(@NonNull IpSecAlgorithm algo) {
+            Objects.requireNonNull(algo);
+            mConfig.setAuthenticatedEncryption(algo);
+            return this;
+        }
+
+        /**
+         * Add UDP encapsulation to an IPv4 transform.
+         *
+         * <p>This allows IPsec traffic to pass through a NAT.
+         *
+         * @see <a href="https://tools.ietf.org/html/rfc3948">RFC 3948, UDP Encapsulation of IPsec
+         *     ESP Packets</a>
+         * @see <a href="https://tools.ietf.org/html/rfc7296#section-2.23">RFC 7296 section 2.23,
+         *     NAT Traversal of IKEv2</a>
+         * @param localSocket a socket for sending and receiving encapsulated traffic
+         * @param remotePort the UDP port number of the remote host that will send and receive
+         *     encapsulated traffic. In the case of IKEv2, this should be port 4500.
+         */
+        @NonNull
+        public IpSecTransform.Builder setIpv4Encapsulation(
+                @NonNull IpSecManager.UdpEncapsulationSocket localSocket, int remotePort) {
+            Objects.requireNonNull(localSocket);
+            mConfig.setEncapType(ENCAP_ESPINUDP);
+            if (localSocket.getResourceId() == INVALID_RESOURCE_ID) {
+                throw new IllegalArgumentException("Invalid UdpEncapsulationSocket");
+            }
+            mConfig.setEncapSocketResourceId(localSocket.getResourceId());
+            mConfig.setEncapRemotePort(remotePort);
+            return this;
+        }
+
+        /**
+         * Build a transport mode {@link IpSecTransform}.
+         *
+         * <p>This builds and activates a transport mode transform. Note that an active transform
+         * will not affect any network traffic until it has been applied to one or more sockets.
+         *
+         * @see IpSecManager#applyTransportModeTransform
+         * @param sourceAddress the source {@code InetAddress} of traffic on sockets that will use
+         *     this transform; this address must belong to the Network used by all sockets that
+         *     utilize this transform; if provided, then only traffic originating from the
+         *     specified source address will be processed.
+         * @param spi a unique {@link IpSecManager.SecurityParameterIndex} to identify transformed
+         *     traffic
+         * @throws IllegalArgumentException indicating that a particular combination of transform
+         *     properties is invalid
+         * @throws IpSecManager.ResourceUnavailableException indicating that too many transforms
+         *     are active
+         * @throws IpSecManager.SpiUnavailableException indicating the rare case where an SPI
+         *     collides with an existing transform
+         * @throws IOException indicating other errors
+         */
+        @NonNull
+        public IpSecTransform buildTransportModeTransform(
+                @NonNull InetAddress sourceAddress,
+                @NonNull IpSecManager.SecurityParameterIndex spi)
+                throws IpSecManager.ResourceUnavailableException,
+                        IpSecManager.SpiUnavailableException, IOException {
+            Objects.requireNonNull(sourceAddress);
+            Objects.requireNonNull(spi);
+            if (spi.getResourceId() == INVALID_RESOURCE_ID) {
+                throw new IllegalArgumentException("Invalid SecurityParameterIndex");
+            }
+            mConfig.setMode(MODE_TRANSPORT);
+            mConfig.setSourceAddress(sourceAddress.getHostAddress());
+            mConfig.setSpiResourceId(spi.getResourceId());
+            // FIXME: modifying a builder after calling build can change the built transform.
+            return new IpSecTransform(mContext, mConfig).activate();
+        }
+
+        /**
+         * Build and return an {@link IpSecTransform} object as a Tunnel Mode Transform. Some
+         * parameters have interdependencies that are checked at build time.
+         *
+         * @param sourceAddress the {@link InetAddress} that provides the source address for this
+         *     IPsec tunnel. This is almost certainly an address belonging to the {@link Network}
+         *     that will originate the traffic, which is set as the {@link #setUnderlyingNetwork}.
+         * @param spi a unique {@link IpSecManager.SecurityParameterIndex} to identify transformed
+         *     traffic
+         * @throws IllegalArgumentException indicating that a particular combination of transform
+         *     properties is invalid.
+         * @throws IpSecManager.ResourceUnavailableException indicating that too many transforms
+         *     are active
+         * @throws IpSecManager.SpiUnavailableException indicating the rare case where an SPI
+         *     collides with an existing transform
+         * @throws IOException indicating other errors
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        @RequiresFeature(PackageManager.FEATURE_IPSEC_TUNNELS)
+        @RequiresPermission(android.Manifest.permission.MANAGE_IPSEC_TUNNELS)
+        public IpSecTransform buildTunnelModeTransform(
+                @NonNull InetAddress sourceAddress,
+                @NonNull IpSecManager.SecurityParameterIndex spi)
+                throws IpSecManager.ResourceUnavailableException,
+                        IpSecManager.SpiUnavailableException, IOException {
+            Objects.requireNonNull(sourceAddress);
+            Objects.requireNonNull(spi);
+            if (spi.getResourceId() == INVALID_RESOURCE_ID) {
+                throw new IllegalArgumentException("Invalid SecurityParameterIndex");
+            }
+            mConfig.setMode(MODE_TUNNEL);
+            mConfig.setSourceAddress(sourceAddress.getHostAddress());
+            mConfig.setSpiResourceId(spi.getResourceId());
+            return new IpSecTransform(mContext, mConfig).activate();
+        }
+
+        /**
+         * Create a new IpSecTransform.Builder.
+         *
+         * @param context current context
+         */
+        public Builder(@NonNull Context context) {
+            Objects.requireNonNull(context);
+            mContext = context;
+            mConfig = new IpSecConfig();
+        }
+    }
+
+    @Override
+    public String toString() {
+        return new StringBuilder()
+            .append("IpSecTransform{resourceId=")
+            .append(mResourceId)
+            .append("}")
+            .toString();
+    }
+}
diff --git a/framework-t/src/android/net/IpSecTransformResponse.aidl b/framework-t/src/android/net/IpSecTransformResponse.aidl
new file mode 100644
index 0000000..546230d
--- /dev/null
+++ b/framework-t/src/android/net/IpSecTransformResponse.aidl
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2017 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;
+
+/** @hide */
+parcelable IpSecTransformResponse;
diff --git a/framework-t/src/android/net/IpSecTransformResponse.java b/framework-t/src/android/net/IpSecTransformResponse.java
new file mode 100644
index 0000000..363f316
--- /dev/null
+++ b/framework-t/src/android/net/IpSecTransformResponse.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2017 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.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * This class is used to return an IpSecTransform resource Id and and corresponding status from the
+ * IpSecService to an IpSecTransform object.
+ *
+ * @hide
+ */
+public final class IpSecTransformResponse implements Parcelable {
+    private static final String TAG = "IpSecTransformResponse";
+
+    public final int resourceId;
+    public final int status;
+    // Parcelable Methods
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeInt(status);
+        out.writeInt(resourceId);
+    }
+
+    public IpSecTransformResponse(int inStatus) {
+        if (inStatus == IpSecManager.Status.OK) {
+            throw new IllegalArgumentException("Valid status implies other args must be provided");
+        }
+        status = inStatus;
+        resourceId = IpSecManager.INVALID_RESOURCE_ID;
+    }
+
+    public IpSecTransformResponse(int inStatus, int inResourceId) {
+        status = inStatus;
+        resourceId = inResourceId;
+    }
+
+    private IpSecTransformResponse(Parcel in) {
+        status = in.readInt();
+        resourceId = in.readInt();
+    }
+
+    @android.annotation.NonNull
+    public static final Parcelable.Creator<IpSecTransformResponse> CREATOR =
+            new Parcelable.Creator<IpSecTransformResponse>() {
+                public IpSecTransformResponse createFromParcel(Parcel in) {
+                    return new IpSecTransformResponse(in);
+                }
+
+                public IpSecTransformResponse[] newArray(int size) {
+                    return new IpSecTransformResponse[size];
+                }
+            };
+}
diff --git a/framework-t/src/android/net/IpSecTunnelInterfaceResponse.aidl b/framework-t/src/android/net/IpSecTunnelInterfaceResponse.aidl
new file mode 100644
index 0000000..7239221
--- /dev/null
+++ b/framework-t/src/android/net/IpSecTunnelInterfaceResponse.aidl
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2018 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;
+
+/** @hide */
+parcelable IpSecTunnelInterfaceResponse;
diff --git a/framework-t/src/android/net/IpSecTunnelInterfaceResponse.java b/framework-t/src/android/net/IpSecTunnelInterfaceResponse.java
new file mode 100644
index 0000000..127e30a
--- /dev/null
+++ b/framework-t/src/android/net/IpSecTunnelInterfaceResponse.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2018 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.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * This class is used to return an IpSecTunnelInterface resource Id and and corresponding status
+ * from the IpSecService to an IpSecTunnelInterface object.
+ *
+ * @hide
+ */
+public final class IpSecTunnelInterfaceResponse implements Parcelable {
+    private static final String TAG = "IpSecTunnelInterfaceResponse";
+
+    public final int resourceId;
+    public final String interfaceName;
+    public final int status;
+    // Parcelable Methods
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeInt(status);
+        out.writeInt(resourceId);
+        out.writeString(interfaceName);
+    }
+
+    public IpSecTunnelInterfaceResponse(int inStatus) {
+        if (inStatus == IpSecManager.Status.OK) {
+            throw new IllegalArgumentException("Valid status implies other args must be provided");
+        }
+        status = inStatus;
+        resourceId = IpSecManager.INVALID_RESOURCE_ID;
+        interfaceName = "";
+    }
+
+    public IpSecTunnelInterfaceResponse(int inStatus, int inResourceId, String inInterfaceName) {
+        status = inStatus;
+        resourceId = inResourceId;
+        interfaceName = inInterfaceName;
+    }
+
+    private IpSecTunnelInterfaceResponse(Parcel in) {
+        status = in.readInt();
+        resourceId = in.readInt();
+        interfaceName = in.readString();
+    }
+
+    @android.annotation.NonNull
+    public static final Parcelable.Creator<IpSecTunnelInterfaceResponse> CREATOR =
+            new Parcelable.Creator<IpSecTunnelInterfaceResponse>() {
+                public IpSecTunnelInterfaceResponse createFromParcel(Parcel in) {
+                    return new IpSecTunnelInterfaceResponse(in);
+                }
+
+                public IpSecTunnelInterfaceResponse[] newArray(int size) {
+                    return new IpSecTunnelInterfaceResponse[size];
+                }
+            };
+}
diff --git a/framework-t/src/android/net/IpSecUdpEncapResponse.aidl b/framework-t/src/android/net/IpSecUdpEncapResponse.aidl
new file mode 100644
index 0000000..5e451f3
--- /dev/null
+++ b/framework-t/src/android/net/IpSecUdpEncapResponse.aidl
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2017 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;
+
+/** @hide */
+parcelable IpSecUdpEncapResponse;
diff --git a/framework-t/src/android/net/IpSecUdpEncapResponse.java b/framework-t/src/android/net/IpSecUdpEncapResponse.java
new file mode 100644
index 0000000..390af82
--- /dev/null
+++ b/framework-t/src/android/net/IpSecUdpEncapResponse.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2017 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.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+
+/**
+ * This class is used to return a UDP Socket and corresponding status from the IpSecService to an
+ * IpSecManager.UdpEncapsulationSocket.
+ *
+ * @hide
+ */
+public final class IpSecUdpEncapResponse implements Parcelable {
+    private static final String TAG = "IpSecUdpEncapResponse";
+
+    public final int resourceId;
+    public final int port;
+    public final int status;
+    // There is a weird asymmetry with FileDescriptor: you can write a FileDescriptor
+    // but you read a ParcelFileDescriptor. To circumvent this, when we receive a FD
+    // from the user, we immediately create a ParcelFileDescriptor DUP, which we invalidate
+    // on writeParcel() by setting the flag to do close-on-write.
+    // TODO: tests to ensure this doesn't leak
+    public final ParcelFileDescriptor fileDescriptor;
+
+    // Parcelable Methods
+
+    @Override
+    public int describeContents() {
+        return (fileDescriptor != null) ? Parcelable.CONTENTS_FILE_DESCRIPTOR : 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeInt(status);
+        out.writeInt(resourceId);
+        out.writeInt(port);
+        out.writeParcelable(fileDescriptor, Parcelable.PARCELABLE_WRITE_RETURN_VALUE);
+    }
+
+    public IpSecUdpEncapResponse(int inStatus) {
+        if (inStatus == IpSecManager.Status.OK) {
+            throw new IllegalArgumentException("Valid status implies other args must be provided");
+        }
+        status = inStatus;
+        resourceId = IpSecManager.INVALID_RESOURCE_ID;
+        port = -1;
+        fileDescriptor = null; // yes I know it's redundant, but readability
+    }
+
+    public IpSecUdpEncapResponse(int inStatus, int inResourceId, int inPort, FileDescriptor inFd)
+            throws IOException {
+        if (inStatus == IpSecManager.Status.OK && inFd == null) {
+            throw new IllegalArgumentException("Valid status implies FD must be non-null");
+        }
+        status = inStatus;
+        resourceId = inResourceId;
+        port = inPort;
+        fileDescriptor = (status == IpSecManager.Status.OK) ? ParcelFileDescriptor.dup(inFd) : null;
+    }
+
+    private IpSecUdpEncapResponse(Parcel in) {
+        status = in.readInt();
+        resourceId = in.readInt();
+        port = in.readInt();
+        fileDescriptor = in.readParcelable(ParcelFileDescriptor.class.getClassLoader(), android.os.ParcelFileDescriptor.class);
+    }
+
+    @android.annotation.NonNull
+    public static final Parcelable.Creator<IpSecUdpEncapResponse> CREATOR =
+            new Parcelable.Creator<IpSecUdpEncapResponse>() {
+                public IpSecUdpEncapResponse createFromParcel(Parcel in) {
+                    return new IpSecUdpEncapResponse(in);
+                }
+
+                public IpSecUdpEncapResponse[] newArray(int size) {
+                    return new IpSecUdpEncapResponse[size];
+                }
+            };
+}
diff --git a/framework-t/src/android/net/NetworkIdentity.java b/framework-t/src/android/net/NetworkIdentity.java
new file mode 100644
index 0000000..da5f88d
--- /dev/null
+++ b/framework-t/src/android/net/NetworkIdentity.java
@@ -0,0 +1,594 @@
+/*
+ * Copyright (C) 2011 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.net.ConnectivityManager.TYPE_MOBILE;
+import static android.net.ConnectivityManager.TYPE_WIFI;
+import static android.net.NetworkTemplate.NETWORK_TYPE_ALL;
+import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SuppressLint;
+import android.annotation.SystemApi;
+import android.app.usage.NetworkStatsManager;
+import android.content.Context;
+import android.net.wifi.WifiInfo;
+import android.service.NetworkIdentityProto;
+import android.telephony.TelephonyManager;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.NetworkCapabilitiesUtils;
+import com.android.net.module.util.NetworkIdentityUtils;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Objects;
+
+/**
+ * Network definition that includes strong identity. Analogous to combining
+ * {@link NetworkCapabilities} and an IMSI.
+ *
+ * @hide
+ */
+@SystemApi(client = MODULE_LIBRARIES)
+public class NetworkIdentity {
+    private static final String TAG = "NetworkIdentity";
+
+    /** @hide */
+    // TODO: Remove this after migrating all callers to use
+    //  {@link NetworkTemplate#NETWORK_TYPE_ALL} instead.
+    public static final int SUBTYPE_COMBINED = -1;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "OEM_MANAGED_" }, flag = true, value = {
+            NetworkTemplate.OEM_MANAGED_NO,
+            NetworkTemplate.OEM_MANAGED_PAID,
+            NetworkTemplate.OEM_MANAGED_PRIVATE
+    })
+    public @interface OemManaged{}
+
+    /**
+     * Network has no {@code NetworkCapabilities#NET_CAPABILITY_OEM_*}.
+     * @hide
+     */
+    public static final int OEM_NONE = 0x0;
+    /**
+     * Network has {@link NetworkCapabilities#NET_CAPABILITY_OEM_PAID}.
+     * @hide
+     */
+    public static final int OEM_PAID = 1 << 0;
+    /**
+     * Network has {@link NetworkCapabilities#NET_CAPABILITY_OEM_PRIVATE}.
+     * @hide
+     */
+    public static final int OEM_PRIVATE = 1 << 1;
+
+    private static final long SUPPORTED_OEM_MANAGED_TYPES = OEM_PAID | OEM_PRIVATE;
+
+    final int mType;
+    final int mRatType;
+    final int mSubId;
+    final String mSubscriberId;
+    final String mWifiNetworkKey;
+    final boolean mRoaming;
+    final boolean mMetered;
+    final boolean mDefaultNetwork;
+    final int mOemManaged;
+
+    /** @hide */
+    public NetworkIdentity(
+            int type, int ratType, @Nullable String subscriberId, @Nullable String wifiNetworkKey,
+            boolean roaming, boolean metered, boolean defaultNetwork, int oemManaged, int subId) {
+        mType = type;
+        mRatType = ratType;
+        mSubscriberId = subscriberId;
+        mWifiNetworkKey = wifiNetworkKey;
+        mRoaming = roaming;
+        mMetered = metered;
+        mDefaultNetwork = defaultNetwork;
+        mOemManaged = oemManaged;
+        mSubId = subId;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mType, mRatType, mSubscriberId, mWifiNetworkKey, mRoaming, mMetered,
+                mDefaultNetwork, mOemManaged, mSubId);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj instanceof NetworkIdentity) {
+            final NetworkIdentity ident = (NetworkIdentity) obj;
+            return mType == ident.mType && mRatType == ident.mRatType && mRoaming == ident.mRoaming
+                    && Objects.equals(mSubscriberId, ident.mSubscriberId)
+                    && Objects.equals(mWifiNetworkKey, ident.mWifiNetworkKey)
+                    && mMetered == ident.mMetered
+                    && mDefaultNetwork == ident.mDefaultNetwork
+                    && mOemManaged == ident.mOemManaged
+                    && mSubId == ident.mSubId;
+        }
+        return false;
+    }
+
+    @Override
+    public String toString() {
+        final StringBuilder builder = new StringBuilder("{");
+        builder.append("type=").append(mType);
+        builder.append(", ratType=");
+        if (mRatType == NETWORK_TYPE_ALL) {
+            builder.append("COMBINED");
+        } else {
+            builder.append(mRatType);
+        }
+        if (mSubscriberId != null) {
+            builder.append(", subscriberId=")
+                    .append(NetworkIdentityUtils.scrubSubscriberId(mSubscriberId));
+        }
+        if (mWifiNetworkKey != null) {
+            builder.append(", wifiNetworkKey=").append(mWifiNetworkKey);
+        }
+        if (mRoaming) {
+            builder.append(", ROAMING");
+        }
+        builder.append(", metered=").append(mMetered);
+        builder.append(", defaultNetwork=").append(mDefaultNetwork);
+        builder.append(", oemManaged=").append(getOemManagedNames(mOemManaged));
+        builder.append(", subId=").append(mSubId);
+        return builder.append("}").toString();
+    }
+
+    /**
+     * Get the human readable representation of a bitfield representing the OEM managed state of a
+     * network.
+     */
+    static String getOemManagedNames(int oemManaged) {
+        if (oemManaged == OEM_NONE) {
+            return "OEM_NONE";
+        }
+        final int[] bitPositions = NetworkCapabilitiesUtils.unpackBits(oemManaged);
+        final ArrayList<String> oemManagedNames = new ArrayList<String>();
+        for (int position : bitPositions) {
+            oemManagedNames.add(nameOfOemManaged(1 << position));
+        }
+        return String.join(",", oemManagedNames);
+    }
+
+    private static String nameOfOemManaged(int oemManagedBit) {
+        switch (oemManagedBit) {
+            case OEM_PAID:
+                return "OEM_PAID";
+            case OEM_PRIVATE:
+                return "OEM_PRIVATE";
+            default:
+                return "Invalid(" + oemManagedBit + ")";
+        }
+    }
+
+    /** @hide */
+    public void dumpDebug(ProtoOutputStream proto, long tag) {
+        final long start = proto.start(tag);
+
+        proto.write(NetworkIdentityProto.TYPE, mType);
+
+        // TODO: dump mRatType as well.
+
+        proto.write(NetworkIdentityProto.ROAMING, mRoaming);
+        proto.write(NetworkIdentityProto.METERED, mMetered);
+        proto.write(NetworkIdentityProto.DEFAULT_NETWORK, mDefaultNetwork);
+        proto.write(NetworkIdentityProto.OEM_MANAGED_NETWORK, mOemManaged);
+
+        proto.end(start);
+    }
+
+    /** Get the network type of this instance. */
+    public int getType() {
+        return mType;
+    }
+
+    /** Get the Radio Access Technology(RAT) type of this instance. */
+    public int getRatType() {
+        return mRatType;
+    }
+
+    /** Get the Subscriber Id of this instance. */
+    @Nullable
+    public String getSubscriberId() {
+        return mSubscriberId;
+    }
+
+    /** Get the Wifi Network Key of this instance. See {@link WifiInfo#getNetworkKey()}. */
+    @Nullable
+    public String getWifiNetworkKey() {
+        return mWifiNetworkKey;
+    }
+
+    /** @hide */
+    // TODO: Remove this function after all callers are removed.
+    public boolean getRoaming() {
+        return mRoaming;
+    }
+
+    /** Return whether this network is roaming. */
+    public boolean isRoaming() {
+        return mRoaming;
+    }
+
+    /** @hide */
+    // TODO: Remove this function after all callers are removed.
+    public boolean getMetered() {
+        return mMetered;
+    }
+
+    /** Return whether this network is metered. */
+    public boolean isMetered() {
+        return mMetered;
+    }
+
+    /** @hide */
+    // TODO: Remove this function after all callers are removed.
+    public boolean getDefaultNetwork() {
+        return mDefaultNetwork;
+    }
+
+    /** Return whether this network is the default network. */
+    public boolean isDefaultNetwork() {
+        return mDefaultNetwork;
+    }
+
+    /** Get the OEM managed type of this instance. */
+    public int getOemManaged() {
+        return mOemManaged;
+    }
+
+    /** Get the SubId of this instance. */
+    public int getSubId() {
+        return mSubId;
+    }
+
+    /**
+     * Assemble a {@link NetworkIdentity} from the passed arguments.
+     *
+     * This methods builds an identity based on the capabilities of the network in the
+     * snapshot and other passed arguments. The identity is used as a key to record data usage.
+     *
+     * @param snapshot the snapshot of network state. See {@link NetworkStateSnapshot}.
+     * @param defaultNetwork whether the network is a default network.
+     * @param ratType the Radio Access Technology(RAT) type of the network. Or
+     *                {@link TelephonyManager#NETWORK_TYPE_UNKNOWN} if not applicable.
+     *                See {@code TelephonyManager.NETWORK_TYPE_*}.
+     * @hide
+     * @deprecated See {@link NetworkIdentity.Builder}.
+     */
+    // TODO: Remove this after all callers are migrated to use new Api.
+    @Deprecated
+    @NonNull
+    public static NetworkIdentity buildNetworkIdentity(Context context,
+            @NonNull NetworkStateSnapshot snapshot, boolean defaultNetwork, int ratType) {
+        final NetworkIdentity.Builder builder = new NetworkIdentity.Builder()
+                .setNetworkStateSnapshot(snapshot).setDefaultNetwork(defaultNetwork)
+                .setSubId(snapshot.getSubId());
+        if (snapshot.getLegacyType() == TYPE_MOBILE && ratType != NETWORK_TYPE_ALL) {
+            builder.setRatType(ratType);
+        }
+        return builder.build();
+    }
+
+    /**
+     * Builds a bitfield of {@code NetworkIdentity.OEM_*} based on {@link NetworkCapabilities}.
+     * @hide
+     */
+    public static int getOemBitfield(@NonNull NetworkCapabilities nc) {
+        int oemManaged = OEM_NONE;
+
+        if (nc.hasCapability(NetworkCapabilities.NET_CAPABILITY_OEM_PAID)) {
+            oemManaged |= OEM_PAID;
+        }
+        if (nc.hasCapability(NetworkCapabilities.NET_CAPABILITY_OEM_PRIVATE)) {
+            oemManaged |= OEM_PRIVATE;
+        }
+
+        return oemManaged;
+    }
+
+    /** @hide */
+    public static int compare(@NonNull NetworkIdentity left, @NonNull NetworkIdentity right) {
+        Objects.requireNonNull(right);
+        int res = Integer.compare(left.mType, right.mType);
+        if (res == 0) {
+            res = Integer.compare(left.mRatType, right.mRatType);
+        }
+        if (res == 0 && left.mSubscriberId != null && right.mSubscriberId != null) {
+            res = left.mSubscriberId.compareTo(right.mSubscriberId);
+        }
+        if (res == 0 && left.mWifiNetworkKey != null && right.mWifiNetworkKey != null) {
+            res = left.mWifiNetworkKey.compareTo(right.mWifiNetworkKey);
+        }
+        if (res == 0) {
+            res = Boolean.compare(left.mRoaming, right.mRoaming);
+        }
+        if (res == 0) {
+            res = Boolean.compare(left.mMetered, right.mMetered);
+        }
+        if (res == 0) {
+            res = Boolean.compare(left.mDefaultNetwork, right.mDefaultNetwork);
+        }
+        if (res == 0) {
+            res = Integer.compare(left.mOemManaged, right.mOemManaged);
+        }
+        if (res == 0) {
+            res = Integer.compare(left.mSubId, right.mSubId);
+        }
+        return res;
+    }
+
+    /**
+     * Builder class for {@link NetworkIdentity}.
+     */
+    public static final class Builder {
+        // Need to be synchronized with ConnectivityManager.
+        // TODO: Use {@link ConnectivityManager#MAX_NETWORK_TYPE} when this file is in the module.
+        private static final int MAX_NETWORK_TYPE = 18; // TYPE_TEST
+        private static final int MIN_NETWORK_TYPE = TYPE_MOBILE;
+
+        private int mType;
+        private int mRatType;
+        private String mSubscriberId;
+        private String mWifiNetworkKey;
+        private boolean mRoaming;
+        private boolean mMetered;
+        private boolean mDefaultNetwork;
+        private int mOemManaged;
+        private int mSubId;
+
+        /**
+         * Creates a new Builder.
+         */
+        public Builder() {
+            // Initialize with default values. Will be overwritten by setters.
+            mType = ConnectivityManager.TYPE_NONE;
+            mRatType = NetworkTemplate.NETWORK_TYPE_ALL;
+            mSubscriberId = null;
+            mWifiNetworkKey = null;
+            mRoaming = false;
+            mMetered = false;
+            mDefaultNetwork = false;
+            mOemManaged = NetworkTemplate.OEM_MANAGED_NO;
+            mSubId = INVALID_SUBSCRIPTION_ID;
+        }
+
+        /**
+         * Add an {@link NetworkStateSnapshot} into the {@link NetworkIdentity} instance.
+         * This is a useful shorthand that will read from the snapshot and set the
+         * following fields, if they are set in the snapshot :
+         *  - type
+         *  - subscriberId
+         *  - roaming
+         *  - metered
+         *  - oemManaged
+         *  - wifiNetworkKey
+         *
+         * @param snapshot The target {@link NetworkStateSnapshot} object.
+         * @return The builder object.
+         */
+        @SuppressLint("MissingGetterMatchingBuilder")
+        @NonNull
+        public Builder setNetworkStateSnapshot(@NonNull NetworkStateSnapshot snapshot) {
+            setType(snapshot.getLegacyType());
+
+            setSubscriberId(snapshot.getSubscriberId());
+            setRoaming(!snapshot.getNetworkCapabilities().hasCapability(
+                    NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING));
+            setMetered(!(snapshot.getNetworkCapabilities().hasCapability(
+                    NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+                    || snapshot.getNetworkCapabilities().hasCapability(
+                    NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED)));
+
+            setOemManaged(getOemBitfield(snapshot.getNetworkCapabilities()));
+
+            if (mType == TYPE_WIFI) {
+                final TransportInfo transportInfo = snapshot.getNetworkCapabilities()
+                        .getTransportInfo();
+                if (transportInfo instanceof WifiInfo) {
+                    final WifiInfo info = (WifiInfo) transportInfo;
+                    setWifiNetworkKey(info.getNetworkKey());
+                }
+            }
+            return this;
+        }
+
+        /**
+         * Set the network type of the network.
+         *
+         * @param type the network type. See {@link ConnectivityManager#TYPE_*}.
+         *
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setType(int type) {
+            // Include TYPE_NONE for compatibility, type field might not be filled by some
+            // networks such as test networks.
+            if ((type < MIN_NETWORK_TYPE || MAX_NETWORK_TYPE < type)
+                    && type != ConnectivityManager.TYPE_NONE) {
+                throw new IllegalArgumentException("Invalid network type: " + type);
+            }
+            mType = type;
+            return this;
+        }
+
+        /**
+         * Set the Radio Access Technology(RAT) type of the network.
+         *
+         * No RAT type is specified by default. Call clearRatType to reset.
+         *
+         * @param ratType the Radio Access Technology(RAT) type if applicable. See
+         *                {@code TelephonyManager.NETWORK_TYPE_*}.
+         *
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setRatType(int ratType) {
+            if (!CollectionUtils.contains(TelephonyManager.getAllNetworkTypes(), ratType)
+                    && ratType != TelephonyManager.NETWORK_TYPE_UNKNOWN
+                    && ratType != NetworkStatsManager.NETWORK_TYPE_5G_NSA) {
+                throw new IllegalArgumentException("Invalid ratType " + ratType);
+            }
+            mRatType = ratType;
+            return this;
+        }
+
+        /**
+         * Clear the Radio Access Technology(RAT) type of the network.
+         *
+         * @return this builder.
+         */
+        @NonNull
+        public Builder clearRatType() {
+            mRatType = NetworkTemplate.NETWORK_TYPE_ALL;
+            return this;
+        }
+
+        /**
+         * Set the Subscriber Id.
+         *
+         * @param subscriberId the Subscriber Id of the network. Or null if not applicable.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setSubscriberId(@Nullable String subscriberId) {
+            mSubscriberId = subscriberId;
+            return this;
+        }
+
+        /**
+         * Set the Wifi Network Key.
+         *
+         * @param wifiNetworkKey Wifi Network Key of the network,
+         *                        see {@link WifiInfo#getNetworkKey()}.
+         *                        Or null if not applicable.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setWifiNetworkKey(@Nullable String wifiNetworkKey) {
+            mWifiNetworkKey = wifiNetworkKey;
+            return this;
+        }
+
+        /**
+         * Set whether this network is roaming.
+         *
+         * This field is false by default. Call with false to reset.
+         *
+         * @param roaming the roaming status of the network.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setRoaming(boolean roaming) {
+            mRoaming = roaming;
+            return this;
+        }
+
+        /**
+         * Set whether this network is metered.
+         *
+         * This field is false by default. Call with false to reset.
+         *
+         * @param metered the meteredness of the network.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setMetered(boolean metered) {
+            mMetered = metered;
+            return this;
+        }
+
+        /**
+         * Set whether this network is the default network.
+         *
+         * This field is false by default. Call with false to reset.
+         *
+         * @param defaultNetwork the default network status of the network.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setDefaultNetwork(boolean defaultNetwork) {
+            mDefaultNetwork = defaultNetwork;
+            return this;
+        }
+
+        /**
+         * Set the OEM managed type.
+         *
+         * @param oemManaged Type of OEM managed network or unmanaged networks.
+         *                   See {@code NetworkTemplate#OEM_MANAGED_*}.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setOemManaged(@OemManaged int oemManaged) {
+            // Assert input does not contain illegal oemManage bits.
+            if ((~SUPPORTED_OEM_MANAGED_TYPES & oemManaged) != 0) {
+                throw new IllegalArgumentException("Invalid value for OemManaged : " + oemManaged);
+            }
+            mOemManaged = oemManaged;
+            return this;
+        }
+
+        /**
+         * Set the Subscription Id.
+         *
+         * @param subId the Subscription Id of the network. Or INVALID_SUBSCRIPTION_ID if not
+         *              applicable.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setSubId(int subId) {
+            mSubId = subId;
+            return this;
+        }
+
+        private void ensureValidParameters() {
+            // Assert non-mobile network cannot have a ratType.
+            if (mType != TYPE_MOBILE && mRatType != NetworkTemplate.NETWORK_TYPE_ALL) {
+                throw new IllegalArgumentException(
+                        "Invalid ratType " + mRatType + " for type " + mType);
+            }
+
+            // Assert non-wifi network cannot have a wifi network key.
+            if (mType != TYPE_WIFI && mWifiNetworkKey != null) {
+                throw new IllegalArgumentException("Invalid wifi network key for type " + mType);
+            }
+        }
+
+        /**
+         * Builds the instance of the {@link NetworkIdentity}.
+         *
+         * @return the built instance of {@link NetworkIdentity}.
+         */
+        @NonNull
+        public NetworkIdentity build() {
+            ensureValidParameters();
+            return new NetworkIdentity(mType, mRatType, mSubscriberId, mWifiNetworkKey,
+                    mRoaming, mMetered, mDefaultNetwork, mOemManaged, mSubId);
+        }
+    }
+}
diff --git a/framework-t/src/android/net/NetworkIdentitySet.java b/framework-t/src/android/net/NetworkIdentitySet.java
new file mode 100644
index 0000000..ad3a958
--- /dev/null
+++ b/framework-t/src/android/net/NetworkIdentitySet.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright (C) 2011 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.ConnectivityManager.TYPE_MOBILE;
+import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+
+import android.annotation.NonNull;
+import android.service.NetworkIdentitySetProto;
+import android.util.proto.ProtoOutputStream;
+
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Identity of a {@code iface}, defined by the set of {@link NetworkIdentity}
+ * active on that interface.
+ *
+ * @hide
+ */
+public class NetworkIdentitySet extends HashSet<NetworkIdentity> {
+    private static final int VERSION_INIT = 1;
+    private static final int VERSION_ADD_ROAMING = 2;
+    private static final int VERSION_ADD_NETWORK_ID = 3;
+    private static final int VERSION_ADD_METERED = 4;
+    private static final int VERSION_ADD_DEFAULT_NETWORK = 5;
+    private static final int VERSION_ADD_OEM_MANAGED_NETWORK = 6;
+    private static final int VERSION_ADD_SUB_ID = 7;
+
+    /**
+     * Construct a {@link NetworkIdentitySet} object.
+     */
+    public NetworkIdentitySet() {
+        super();
+    }
+
+    /** @hide */
+    public NetworkIdentitySet(@NonNull Set<NetworkIdentity> ident) {
+        super(ident);
+    }
+
+    /** @hide */
+    public NetworkIdentitySet(DataInput in) throws IOException {
+        final int version = in.readInt();
+        final int size = in.readInt();
+        for (int i = 0; i < size; i++) {
+            if (version <= VERSION_INIT) {
+                final int ignored = in.readInt();
+            }
+            final int type = in.readInt();
+            final int ratType = in.readInt();
+            final String subscriberId = readOptionalString(in);
+            final String networkId;
+            if (version >= VERSION_ADD_NETWORK_ID) {
+                networkId = readOptionalString(in);
+            } else {
+                networkId = null;
+            }
+            final boolean roaming;
+            if (version >= VERSION_ADD_ROAMING) {
+                roaming = in.readBoolean();
+            } else {
+                roaming = false;
+            }
+
+            final boolean metered;
+            if (version >= VERSION_ADD_METERED) {
+                metered = in.readBoolean();
+            } else {
+                // If this is the old data and the type is mobile, treat it as metered. (Note that
+                // if this is a mobile network, TYPE_MOBILE is the only possible type that could be
+                // used.)
+                metered = (type == TYPE_MOBILE);
+            }
+
+            final boolean defaultNetwork;
+            if (version >= VERSION_ADD_DEFAULT_NETWORK) {
+                defaultNetwork = in.readBoolean();
+            } else {
+                defaultNetwork = true;
+            }
+
+            final int oemNetCapabilities;
+            if (version >= VERSION_ADD_OEM_MANAGED_NETWORK) {
+                oemNetCapabilities = in.readInt();
+            } else {
+                oemNetCapabilities = NetworkIdentity.OEM_NONE;
+            }
+
+            final int subId;
+            if (version >= VERSION_ADD_SUB_ID) {
+                subId = in.readInt();
+            } else {
+                subId = INVALID_SUBSCRIPTION_ID;
+            }
+
+            add(new NetworkIdentity(type, ratType, subscriberId, networkId, roaming, metered,
+                    defaultNetwork, oemNetCapabilities, subId));
+        }
+    }
+
+    /**
+     * Method to serialize this object into a {@code DataOutput}.
+     * @hide
+     */
+    public void writeToStream(DataOutput out) throws IOException {
+        out.writeInt(VERSION_ADD_SUB_ID);
+        out.writeInt(size());
+        for (NetworkIdentity ident : this) {
+            out.writeInt(ident.getType());
+            out.writeInt(ident.getRatType());
+            writeOptionalString(out, ident.getSubscriberId());
+            writeOptionalString(out, ident.getWifiNetworkKey());
+            out.writeBoolean(ident.isRoaming());
+            out.writeBoolean(ident.isMetered());
+            out.writeBoolean(ident.isDefaultNetwork());
+            out.writeInt(ident.getOemManaged());
+            out.writeInt(ident.getSubId());
+        }
+    }
+
+    /**
+     * @return whether any {@link NetworkIdentity} in this set is considered metered.
+     * @hide
+     */
+    public boolean isAnyMemberMetered() {
+        if (isEmpty()) {
+            return false;
+        }
+        for (NetworkIdentity ident : this) {
+            if (ident.isMetered()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @return whether any {@link NetworkIdentity} in this set is considered roaming.
+     * @hide
+     */
+    public boolean isAnyMemberRoaming() {
+        if (isEmpty()) {
+            return false;
+        }
+        for (NetworkIdentity ident : this) {
+            if (ident.isRoaming()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @return whether any {@link NetworkIdentity} in this set is considered on the default
+     *         network.
+     * @hide
+     */
+    public boolean areAllMembersOnDefaultNetwork() {
+        if (isEmpty()) {
+            return true;
+        }
+        for (NetworkIdentity ident : this) {
+            if (!ident.isDefaultNetwork()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static void writeOptionalString(DataOutput out, String value) throws IOException {
+        if (value != null) {
+            out.writeByte(1);
+            out.writeUTF(value);
+        } else {
+            out.writeByte(0);
+        }
+    }
+
+    private static String readOptionalString(DataInput in) throws IOException {
+        if (in.readByte() != 0) {
+            return in.readUTF();
+        } else {
+            return null;
+        }
+    }
+
+    public static int compare(@NonNull NetworkIdentitySet left, @NonNull NetworkIdentitySet right) {
+        Objects.requireNonNull(left);
+        Objects.requireNonNull(right);
+        if (left.isEmpty()) return -1;
+        if (right.isEmpty()) return 1;
+
+        final NetworkIdentity leftIdent = left.iterator().next();
+        final NetworkIdentity rightIdent = right.iterator().next();
+        return NetworkIdentity.compare(leftIdent, rightIdent);
+    }
+
+    /**
+     * Method to dump this object into proto debug file.
+     * @hide
+     */
+    public void dumpDebug(ProtoOutputStream proto, long tag) {
+        final long start = proto.start(tag);
+
+        for (NetworkIdentity ident : this) {
+            ident.dumpDebug(proto, NetworkIdentitySetProto.IDENTITIES);
+        }
+
+        proto.end(start);
+    }
+}
diff --git a/framework-t/src/android/net/NetworkStateSnapshot.java b/framework-t/src/android/net/NetworkStateSnapshot.java
new file mode 100644
index 0000000..c018e91
--- /dev/null
+++ b/framework-t/src/android/net/NetworkStateSnapshot.java
@@ -0,0 +1,192 @@
+/*
+ * 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 android.net;
+
+import static android.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.net.module.util.NetworkIdentityUtils;
+
+import java.util.Objects;
+
+/**
+ * Snapshot of network state.
+ *
+ * @hide
+ */
+@SystemApi(client = MODULE_LIBRARIES)
+public final class NetworkStateSnapshot implements Parcelable {
+    /** The network associated with this snapshot. */
+    @NonNull
+    private final Network mNetwork;
+
+    /** The {@link NetworkCapabilities} of the network associated with this snapshot. */
+    @NonNull
+    private final NetworkCapabilities mNetworkCapabilities;
+
+    /** The {@link LinkProperties} of the network associated with this snapshot. */
+    @NonNull
+    private final LinkProperties mLinkProperties;
+
+    /**
+     * The Subscriber Id of the network associated with this snapshot. See
+     * {@link android.telephony.TelephonyManager#getSubscriberId()}.
+     */
+    @Nullable
+    private final String mSubscriberId;
+
+    /**
+     * The legacy type of the network associated with this snapshot. See
+     * {@code ConnectivityManager#TYPE_*}.
+     */
+    private final int mLegacyType;
+
+    public NetworkStateSnapshot(@NonNull Network network,
+            @NonNull NetworkCapabilities networkCapabilities,
+            @NonNull LinkProperties linkProperties,
+            @Nullable String subscriberId, int legacyType) {
+        mNetwork = Objects.requireNonNull(network);
+        mNetworkCapabilities = Objects.requireNonNull(networkCapabilities);
+        mLinkProperties = Objects.requireNonNull(linkProperties);
+        mSubscriberId = subscriberId;
+        mLegacyType = legacyType;
+    }
+
+    /** @hide */
+    public NetworkStateSnapshot(@NonNull Parcel in) {
+        mNetwork = in.readParcelable(null, android.net.Network.class);
+        mNetworkCapabilities = in.readParcelable(null, android.net.NetworkCapabilities.class);
+        mLinkProperties = in.readParcelable(null, android.net.LinkProperties.class);
+        mSubscriberId = in.readString();
+        mLegacyType = in.readInt();
+    }
+
+    /** Get the network associated with this snapshot */
+    @NonNull
+    public Network getNetwork() {
+        return mNetwork;
+    }
+
+    /** Get {@link NetworkCapabilities} of the network associated with this snapshot. */
+    @NonNull
+    public NetworkCapabilities getNetworkCapabilities() {
+        return mNetworkCapabilities;
+    }
+
+    /** Get the {@link LinkProperties} of the network associated with this snapshot. */
+    @NonNull
+    public LinkProperties getLinkProperties() {
+        return mLinkProperties;
+    }
+
+    /**
+     * Get the Subscriber Id of the network associated with this snapshot.
+     * @deprecated Please use #getSubId, which doesn't return personally identifiable
+     * information.
+     */
+    @Deprecated
+    @Nullable
+    public String getSubscriberId() {
+        return mSubscriberId;
+    }
+
+    /** Get the subId of the network associated with this snapshot. */
+    public int getSubId() {
+        if (mNetworkCapabilities.hasTransport(TRANSPORT_CELLULAR)) {
+            final NetworkSpecifier spec = mNetworkCapabilities.getNetworkSpecifier();
+            if (spec instanceof TelephonyNetworkSpecifier) {
+                return ((TelephonyNetworkSpecifier) spec).getSubscriptionId();
+            }
+        }
+        return INVALID_SUBSCRIPTION_ID;
+    }
+
+
+    /**
+     * Get the legacy type of the network associated with this snapshot.
+     * @return the legacy network type. See {@code ConnectivityManager#TYPE_*}.
+     */
+    public int getLegacyType() {
+        return mLegacyType;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel out, int flags) {
+        out.writeParcelable(mNetwork, flags);
+        out.writeParcelable(mNetworkCapabilities, flags);
+        out.writeParcelable(mLinkProperties, flags);
+        out.writeString(mSubscriberId);
+        out.writeInt(mLegacyType);
+    }
+
+    @NonNull
+    public static final Creator<NetworkStateSnapshot> CREATOR =
+            new Creator<NetworkStateSnapshot>() {
+        @NonNull
+        @Override
+        public NetworkStateSnapshot createFromParcel(@NonNull Parcel in) {
+            return new NetworkStateSnapshot(in);
+        }
+
+        @NonNull
+        @Override
+        public NetworkStateSnapshot[] newArray(int size) {
+            return new NetworkStateSnapshot[size];
+        }
+    };
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof NetworkStateSnapshot)) return false;
+        NetworkStateSnapshot that = (NetworkStateSnapshot) o;
+        return mLegacyType == that.mLegacyType
+                && Objects.equals(mNetwork, that.mNetwork)
+                && Objects.equals(mNetworkCapabilities, that.mNetworkCapabilities)
+                && Objects.equals(mLinkProperties, that.mLinkProperties)
+                && Objects.equals(mSubscriberId, that.mSubscriberId);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mNetwork,
+                mNetworkCapabilities, mLinkProperties, mSubscriberId, mLegacyType);
+    }
+
+    @Override
+    public String toString() {
+        return "NetworkStateSnapshot{"
+                + "network=" + mNetwork
+                + ", networkCapabilities=" + mNetworkCapabilities
+                + ", linkProperties=" + mLinkProperties
+                + ", subscriberId='" + NetworkIdentityUtils.scrubSubscriberId(mSubscriberId) + '\''
+                + ", legacyType=" + mLegacyType
+                + '}';
+    }
+}
diff --git a/framework-t/src/android/net/NetworkStats.java b/framework-t/src/android/net/NetworkStats.java
new file mode 100644
index 0000000..bcfeab9
--- /dev/null
+++ b/framework-t/src/android/net/NetworkStats.java
@@ -0,0 +1,1860 @@
+/*
+ * Copyright (C) 2011 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+
+import static com.android.net.module.util.NetworkStatsUtils.multiplySafeByRational;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.compat.annotation.UnsupportedAppUsage;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.Process;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.SparseBooleanArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.CollectionUtils;
+
+import libcore.util.EmptyArray;
+
+import java.io.CharArrayWriter;
+import java.io.PrintWriter;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Predicate;
+
+/**
+ * Collection of active network statistics. Can contain summary details across
+ * all interfaces, or details with per-UID granularity. Internally stores data
+ * as a large table, closely matching {@code /proc/} data format. This structure
+ * optimizes for rapid in-memory comparison, but consider using
+ * {@link NetworkStatsHistory} when persisting.
+ *
+ * @hide
+ */
+// @NotThreadSafe
+@SystemApi
+public final class NetworkStats implements Parcelable, Iterable<NetworkStats.Entry> {
+    private static final String TAG = "NetworkStats";
+
+    /**
+     * {@link #iface} value when interface details unavailable.
+     * @hide
+     */
+    @Nullable public static final String IFACE_ALL = null;
+
+    /**
+     * Virtual network interface for video telephony. This is for VT data usage counting
+     * purpose.
+     */
+    public static final String IFACE_VT = "vt_data0";
+
+    /** {@link #uid} value when UID details unavailable. */
+    public static final int UID_ALL = -1;
+    /** Special UID value for data usage by tethering. */
+    public static final int UID_TETHERING = -5;
+
+    /**
+     * {@link #tag} value matching any tag.
+     * @hide
+     */
+    // TODO: Rename TAG_ALL to TAG_ANY.
+    public static final int TAG_ALL = -1;
+    /** {@link #set} value for all sets combined, not including debug sets. */
+    public static final int SET_ALL = -1;
+    /** {@link #set} value where background data is accounted. */
+    public static final int SET_DEFAULT = 0;
+    /** {@link #set} value where foreground data is accounted. */
+    public static final int SET_FOREGROUND = 1;
+    /**
+     * All {@link #set} value greater than SET_DEBUG_START are debug {@link #set} values.
+     * @hide
+     */
+    public static final int SET_DEBUG_START = 1000;
+    /**
+     * Debug {@link #set} value when the VPN stats are moved in.
+     * @hide
+     */
+    public static final int SET_DBG_VPN_IN = 1001;
+    /**
+     * Debug {@link #set} value when the VPN stats are moved out of a vpn UID.
+     * @hide
+     */
+    public static final int SET_DBG_VPN_OUT = 1002;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "SET_" }, value = {
+            SET_ALL,
+            SET_DEFAULT,
+            SET_FOREGROUND,
+    })
+    public @interface State {
+    }
+
+    /**
+     * Include all interfaces when filtering
+     * @hide
+     */
+    public @Nullable static final String[] INTERFACES_ALL = null;
+
+    /** {@link #tag} value for total data across all tags. */
+    public static final int TAG_NONE = 0;
+
+    /** {@link #metered} value to account for all metered states. */
+    public static final int METERED_ALL = -1;
+    /** {@link #metered} value where native, unmetered data is accounted. */
+    public static final int METERED_NO = 0;
+    /** {@link #metered} value where metered data is accounted. */
+    public static final int METERED_YES = 1;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "METERED_" }, value = {
+            METERED_ALL,
+            METERED_NO,
+            METERED_YES
+    })
+    public @interface Meteredness {
+    }
+
+
+    /** {@link #roaming} value to account for all roaming states. */
+    public static final int ROAMING_ALL = -1;
+    /** {@link #roaming} value where native, non-roaming data is accounted. */
+    public static final int ROAMING_NO = 0;
+    /** {@link #roaming} value where roaming data is accounted. */
+    public static final int ROAMING_YES = 1;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "ROAMING_" }, value = {
+            ROAMING_ALL,
+            ROAMING_NO,
+            ROAMING_YES
+    })
+    public @interface Roaming {
+    }
+
+    /** {@link #onDefaultNetwork} value to account for all default network states. */
+    public static final int DEFAULT_NETWORK_ALL = -1;
+    /** {@link #onDefaultNetwork} value to account for usage while not the default network. */
+    public static final int DEFAULT_NETWORK_NO = 0;
+    /** {@link #onDefaultNetwork} value to account for usage while the default network. */
+    public static final int DEFAULT_NETWORK_YES = 1;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "DEFAULT_NETWORK_" }, value = {
+            DEFAULT_NETWORK_ALL,
+            DEFAULT_NETWORK_NO,
+            DEFAULT_NETWORK_YES
+    })
+    public @interface DefaultNetwork {
+    }
+
+    /**
+     * Denotes a request for stats at the interface level.
+     * @hide
+     */
+    public static final int STATS_PER_IFACE = 0;
+    /**
+     * Denotes a request for stats at the interface and UID level.
+     * @hide
+     */
+    public static final int STATS_PER_UID = 1;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "STATS_PER_" }, value = {
+            STATS_PER_IFACE,
+            STATS_PER_UID
+    })
+    public @interface StatsType {
+    }
+
+    private static final String CLATD_INTERFACE_PREFIX = "v4-";
+    // Delta between IPv4 header (20b) and IPv6 header (40b).
+    // Used for correct stats accounting on clatd interfaces.
+    private static final int IPV4V6_HEADER_DELTA = 20;
+
+    // TODO: move fields to "mVariable" notation
+
+    /**
+     * {@link SystemClock#elapsedRealtime()} timestamp in milliseconds when this data was
+     * generated.
+     * It's a timestamps delta when {@link #subtract()},
+     * {@code INetworkStatsSession#getSummaryForAllUid()} methods are used.
+     */
+    private long elapsedRealtime;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private int size;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private int capacity;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private String[] iface;
+    @UnsupportedAppUsage
+    private int[] uid;
+    @UnsupportedAppUsage
+    private int[] set;
+    @UnsupportedAppUsage
+    private int[] tag;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private int[] metered;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private int[] roaming;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private int[] defaultNetwork;
+    @UnsupportedAppUsage
+    private long[] rxBytes;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private long[] rxPackets;
+    @UnsupportedAppUsage
+    private long[] txBytes;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private long[] txPackets;
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    private long[] operations;
+
+    /**
+     * Basic element of network statistics. Contains the number of packets and number of bytes
+     * transferred on both directions in a given set of conditions. See
+     * {@link Entry#Entry(String, int, int, int, int, int, int, long, long, long, long, long)}.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static class Entry {
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public String iface;
+        /** @hide */
+        @UnsupportedAppUsage
+        public int uid;
+        /** @hide */
+        @UnsupportedAppUsage
+        public int set;
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public int tag;
+        /**
+         * Note that this is only populated w/ the default value when read from /proc or written
+         * to disk. We merge in the correct value when reporting this value to clients of
+         * getSummary().
+         * @hide
+         */
+        public int metered;
+        /**
+         * Note that this is only populated w/ the default value when read from /proc or written
+         * to disk. We merge in the correct value when reporting this value to clients of
+         * getSummary().
+         * @hide
+         */
+        public int roaming;
+        /**
+         * Note that this is only populated w/ the default value when read from /proc or written
+         * to disk. We merge in the correct value when reporting this value to clients of
+         * getSummary().
+         * @hide
+         */
+        public int defaultNetwork;
+        /** @hide */
+        @UnsupportedAppUsage
+        public long rxBytes;
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public long rxPackets;
+        /** @hide */
+        @UnsupportedAppUsage
+        public long txBytes;
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public long txPackets;
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public long operations;
+
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public Entry() {
+            this(IFACE_ALL, UID_ALL, SET_DEFAULT, TAG_NONE, 0L, 0L, 0L, 0L, 0L);
+        }
+
+        /** @hide */
+        public Entry(long rxBytes, long rxPackets, long txBytes, long txPackets, long operations) {
+            this(IFACE_ALL, UID_ALL, SET_DEFAULT, TAG_NONE, rxBytes, rxPackets, txBytes, txPackets,
+                    operations);
+        }
+
+        /** @hide */
+        public Entry(String iface, int uid, int set, int tag, long rxBytes, long rxPackets,
+                long txBytes, long txPackets, long operations) {
+            this(iface, uid, set, tag, METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO,
+                    rxBytes, rxPackets, txBytes, txPackets, operations);
+        }
+
+        /**
+         * Construct a {@link Entry} object by giving statistics of packet and byte transferred on
+         * both direction, and associated with a set of given conditions.
+         *
+         * @param iface interface name of this {@link Entry}. Or null if not specified.
+         * @param uid uid of this {@link Entry}. {@link #UID_TETHERING} if this {@link Entry} is
+         *            for tethering. Or {@link #UID_ALL} if this {@link NetworkStats} is only
+         *            counting iface stats.
+         * @param set usage state of this {@link Entry}.
+         * @param tag tag of this {@link Entry}.
+         * @param metered metered state of this {@link Entry}.
+         * @param roaming roaming state of this {@link Entry}.
+         * @param defaultNetwork default network status of this {@link Entry}.
+         * @param rxBytes Number of bytes received for this {@link Entry}. Statistics should
+         *                represent the contents of IP packets, including IP headers.
+         * @param rxPackets Number of packets received for this {@link Entry}. Statistics should
+         *                  represent the contents of IP packets, including IP headers.
+         * @param txBytes Number of bytes transmitted for this {@link Entry}. Statistics should
+         *                represent the contents of IP packets, including IP headers.
+         * @param txPackets Number of bytes transmitted for this {@link Entry}. Statistics should
+         *                  represent the contents of IP packets, including IP headers.
+         * @param operations count of network operations performed for this {@link Entry}. This can
+         *                   be used to derive bytes-per-operation.
+         */
+        public Entry(@Nullable String iface, int uid, @State int set, int tag,
+                @Meteredness int metered, @Roaming int roaming, @DefaultNetwork int defaultNetwork,
+                long rxBytes, long rxPackets, long txBytes, long txPackets, long operations) {
+            this.iface = iface;
+            this.uid = uid;
+            this.set = set;
+            this.tag = tag;
+            this.metered = metered;
+            this.roaming = roaming;
+            this.defaultNetwork = defaultNetwork;
+            this.rxBytes = rxBytes;
+            this.rxPackets = rxPackets;
+            this.txBytes = txBytes;
+            this.txPackets = txPackets;
+            this.operations = operations;
+        }
+
+        /** @hide */
+        public boolean isNegative() {
+            return rxBytes < 0 || rxPackets < 0 || txBytes < 0 || txPackets < 0 || operations < 0;
+        }
+
+        /** @hide */
+        public boolean isEmpty() {
+            return rxBytes == 0 && rxPackets == 0 && txBytes == 0 && txPackets == 0
+                    && operations == 0;
+        }
+
+        /** @hide */
+        public void add(Entry another) {
+            this.rxBytes += another.rxBytes;
+            this.rxPackets += another.rxPackets;
+            this.txBytes += another.txBytes;
+            this.txPackets += another.txPackets;
+            this.operations += another.operations;
+        }
+
+        /**
+         * @return interface name of this entry.
+         * @hide
+         */
+        @Nullable public String getIface() {
+            return iface;
+        }
+
+        /**
+         * @return the uid of this entry.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public int getUid() {
+            return uid;
+        }
+
+        /**
+         * @return the set state of this entry.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        @State public int getSet() {
+            return set;
+        }
+
+        /**
+         * @return the tag value of this entry.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public int getTag() {
+            return tag;
+        }
+
+        /**
+         * @return the metered state.
+         * @hide
+         */
+        @Meteredness
+        @SystemApi(client = MODULE_LIBRARIES)
+        public int getMetered() {
+            return metered;
+        }
+
+        /**
+         * @return the roaming state.
+         * @hide
+         */
+        @Roaming
+        @SystemApi(client = MODULE_LIBRARIES)
+        public int getRoaming() {
+            return roaming;
+        }
+
+        /**
+         * @return the default network state.
+         * @hide
+         */
+        @DefaultNetwork
+        @SystemApi(client = MODULE_LIBRARIES)
+        public int getDefaultNetwork() {
+            return defaultNetwork;
+        }
+
+        /**
+         * @return the number of received bytes.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public long getRxBytes() {
+            return rxBytes;
+        }
+
+        /**
+         * @return the number of received packets.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public long getRxPackets() {
+            return rxPackets;
+        }
+
+        /**
+         * @return the number of transmitted bytes.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public long getTxBytes() {
+            return txBytes;
+        }
+
+        /**
+         * @return the number of transmitted packets.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public long getTxPackets() {
+            return txPackets;
+        }
+
+        /**
+         * @return the count of network operations performed for this entry.
+         * @hide
+         */
+        @SystemApi(client = MODULE_LIBRARIES)
+        public long getOperations() {
+            return operations;
+        }
+
+        @Override
+        public String toString() {
+            final StringBuilder builder = new StringBuilder();
+            builder.append("iface=").append(iface);
+            builder.append(" uid=").append(uid);
+            builder.append(" set=").append(setToString(set));
+            builder.append(" tag=").append(tagToString(tag));
+            builder.append(" metered=").append(meteredToString(metered));
+            builder.append(" roaming=").append(roamingToString(roaming));
+            builder.append(" defaultNetwork=").append(defaultNetworkToString(defaultNetwork));
+            builder.append(" rxBytes=").append(rxBytes);
+            builder.append(" rxPackets=").append(rxPackets);
+            builder.append(" txBytes=").append(txBytes);
+            builder.append(" txPackets=").append(txPackets);
+            builder.append(" operations=").append(operations);
+            return builder.toString();
+        }
+
+        /** @hide */
+        @Override
+        public boolean equals(@Nullable Object o) {
+            if (o instanceof Entry) {
+                final Entry e = (Entry) o;
+                return uid == e.uid && set == e.set && tag == e.tag && metered == e.metered
+                        && roaming == e.roaming && defaultNetwork == e.defaultNetwork
+                        && rxBytes == e.rxBytes && rxPackets == e.rxPackets
+                        && txBytes == e.txBytes && txPackets == e.txPackets
+                        && operations == e.operations && TextUtils.equals(iface, e.iface);
+            }
+            return false;
+        }
+
+        /** @hide */
+        @Override
+        public int hashCode() {
+            return Objects.hash(uid, set, tag, metered, roaming, defaultNetwork, iface);
+        }
+    }
+
+    public NetworkStats(long elapsedRealtime, int initialSize) {
+        this.elapsedRealtime = elapsedRealtime;
+        this.size = 0;
+        if (initialSize > 0) {
+            this.capacity = initialSize;
+            this.iface = new String[initialSize];
+            this.uid = new int[initialSize];
+            this.set = new int[initialSize];
+            this.tag = new int[initialSize];
+            this.metered = new int[initialSize];
+            this.roaming = new int[initialSize];
+            this.defaultNetwork = new int[initialSize];
+            this.rxBytes = new long[initialSize];
+            this.rxPackets = new long[initialSize];
+            this.txBytes = new long[initialSize];
+            this.txPackets = new long[initialSize];
+            this.operations = new long[initialSize];
+        } else {
+            // Special case for use by NetworkStatsFactory to start out *really* empty.
+            clear();
+        }
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public NetworkStats(Parcel parcel) {
+        elapsedRealtime = parcel.readLong();
+        size = parcel.readInt();
+        capacity = parcel.readInt();
+        iface = parcel.createStringArray();
+        uid = parcel.createIntArray();
+        set = parcel.createIntArray();
+        tag = parcel.createIntArray();
+        metered = parcel.createIntArray();
+        roaming = parcel.createIntArray();
+        defaultNetwork = parcel.createIntArray();
+        rxBytes = parcel.createLongArray();
+        rxPackets = parcel.createLongArray();
+        txBytes = parcel.createLongArray();
+        txPackets = parcel.createLongArray();
+        operations = parcel.createLongArray();
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeLong(elapsedRealtime);
+        dest.writeInt(size);
+        dest.writeInt(capacity);
+        dest.writeStringArray(iface);
+        dest.writeIntArray(uid);
+        dest.writeIntArray(set);
+        dest.writeIntArray(tag);
+        dest.writeIntArray(metered);
+        dest.writeIntArray(roaming);
+        dest.writeIntArray(defaultNetwork);
+        dest.writeLongArray(rxBytes);
+        dest.writeLongArray(rxPackets);
+        dest.writeLongArray(txBytes);
+        dest.writeLongArray(txPackets);
+        dest.writeLongArray(operations);
+    }
+
+    /**
+     * @hide
+     */
+    @Override
+    public NetworkStats clone() {
+        final NetworkStats clone = new NetworkStats(elapsedRealtime, size);
+        NetworkStats.Entry entry = null;
+        for (int i = 0; i < size; i++) {
+            entry = getValues(i, entry);
+            clone.insertEntry(entry);
+        }
+        return clone;
+    }
+
+    /**
+     * Clear all data stored in this object.
+     * @hide
+     */
+    public void clear() {
+        this.capacity = 0;
+        this.iface = EmptyArray.STRING;
+        this.uid = EmptyArray.INT;
+        this.set = EmptyArray.INT;
+        this.tag = EmptyArray.INT;
+        this.metered = EmptyArray.INT;
+        this.roaming = EmptyArray.INT;
+        this.defaultNetwork = EmptyArray.INT;
+        this.rxBytes = EmptyArray.LONG;
+        this.rxPackets = EmptyArray.LONG;
+        this.txBytes = EmptyArray.LONG;
+        this.txPackets = EmptyArray.LONG;
+        this.operations = EmptyArray.LONG;
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public NetworkStats insertEntry(
+            String iface, long rxBytes, long rxPackets, long txBytes, long txPackets) {
+        return insertEntry(
+                iface, UID_ALL, SET_DEFAULT, TAG_NONE, rxBytes, rxPackets, txBytes, txPackets, 0L);
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public NetworkStats insertEntry(String iface, int uid, int set, int tag, long rxBytes,
+            long rxPackets, long txBytes, long txPackets, long operations) {
+        return insertEntry(new Entry(
+                iface, uid, set, tag, rxBytes, rxPackets, txBytes, txPackets, operations));
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public NetworkStats insertEntry(String iface, int uid, int set, int tag, int metered,
+            int roaming, int defaultNetwork, long rxBytes, long rxPackets, long txBytes,
+            long txPackets, long operations) {
+        return insertEntry(new Entry(
+                iface, uid, set, tag, metered, roaming, defaultNetwork, rxBytes, rxPackets,
+                txBytes, txPackets, operations));
+    }
+
+    /**
+     * Add new stats entry, copying from given {@link Entry}. The {@link Entry}
+     * object can be recycled across multiple calls.
+     * @hide
+     */
+    public NetworkStats insertEntry(Entry entry) {
+        if (size >= capacity) {
+            final int newLength = Math.max(size, 10) * 3 / 2;
+            iface = Arrays.copyOf(iface, newLength);
+            uid = Arrays.copyOf(uid, newLength);
+            set = Arrays.copyOf(set, newLength);
+            tag = Arrays.copyOf(tag, newLength);
+            metered = Arrays.copyOf(metered, newLength);
+            roaming = Arrays.copyOf(roaming, newLength);
+            defaultNetwork = Arrays.copyOf(defaultNetwork, newLength);
+            rxBytes = Arrays.copyOf(rxBytes, newLength);
+            rxPackets = Arrays.copyOf(rxPackets, newLength);
+            txBytes = Arrays.copyOf(txBytes, newLength);
+            txPackets = Arrays.copyOf(txPackets, newLength);
+            operations = Arrays.copyOf(operations, newLength);
+            capacity = newLength;
+        }
+
+        setValues(size, entry);
+        size++;
+
+        return this;
+    }
+
+    private void setValues(int i, Entry entry) {
+        iface[i] = entry.iface;
+        uid[i] = entry.uid;
+        set[i] = entry.set;
+        tag[i] = entry.tag;
+        metered[i] = entry.metered;
+        roaming[i] = entry.roaming;
+        defaultNetwork[i] = entry.defaultNetwork;
+        rxBytes[i] = entry.rxBytes;
+        rxPackets[i] = entry.rxPackets;
+        txBytes[i] = entry.txBytes;
+        txPackets[i] = entry.txPackets;
+        operations[i] = entry.operations;
+    }
+
+    /**
+     * Iterate over Entry objects.
+     *
+     * Return an iterator of this object that will iterate through all contained Entry objects.
+     *
+     * This iterator does not support concurrent modification and makes no guarantee of fail-fast
+     * behavior. If any method that can mutate the contents of this object is called while
+     * iteration is in progress, either inside the loop or in another thread, then behavior is
+     * undefined.
+     * The remove() method is not implemented and will throw UnsupportedOperationException.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @NonNull public Iterator<Entry> iterator() {
+        return new Iterator<Entry>() {
+            int mIndex = 0;
+
+            @Override
+            public boolean hasNext() {
+                return mIndex < size;
+            }
+
+            @Override
+            public Entry next() {
+                return getValues(mIndex++, null);
+            }
+        };
+    }
+
+    /**
+     * Return specific stats entry.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public Entry getValues(int i, @Nullable Entry recycle) {
+        final Entry entry = recycle != null ? recycle : new Entry();
+        entry.iface = iface[i];
+        entry.uid = uid[i];
+        entry.set = set[i];
+        entry.tag = tag[i];
+        entry.metered = metered[i];
+        entry.roaming = roaming[i];
+        entry.defaultNetwork = defaultNetwork[i];
+        entry.rxBytes = rxBytes[i];
+        entry.rxPackets = rxPackets[i];
+        entry.txBytes = txBytes[i];
+        entry.txPackets = txPackets[i];
+        entry.operations = operations[i];
+        return entry;
+    }
+
+    /**
+     * If @{code dest} is not equal to @{code src}, copy entry from index @{code src} to index
+     * @{code dest}.
+     */
+    private void maybeCopyEntry(int dest, int src) {
+        if (dest == src) return;
+        iface[dest] = iface[src];
+        uid[dest] = uid[src];
+        set[dest] = set[src];
+        tag[dest] = tag[src];
+        metered[dest] = metered[src];
+        roaming[dest] = roaming[src];
+        defaultNetwork[dest] = defaultNetwork[src];
+        rxBytes[dest] = rxBytes[src];
+        rxPackets[dest] = rxPackets[src];
+        txBytes[dest] = txBytes[src];
+        txPackets[dest] = txPackets[src];
+        operations[dest] = operations[src];
+    }
+
+    /** @hide */
+    public long getElapsedRealtime() {
+        return elapsedRealtime;
+    }
+
+    /** @hide */
+    public void setElapsedRealtime(long time) {
+        elapsedRealtime = time;
+    }
+
+    /**
+     * Return age of this {@link NetworkStats} object with respect to
+     * {@link SystemClock#elapsedRealtime()}.
+     * @hide
+     */
+    public long getElapsedRealtimeAge() {
+        return SystemClock.elapsedRealtime() - elapsedRealtime;
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage
+    public int size() {
+        return size;
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public int internalSize() {
+        return capacity;
+    }
+
+    /** @hide */
+    @Deprecated
+    public NetworkStats combineValues(String iface, int uid, int tag, long rxBytes, long rxPackets,
+            long txBytes, long txPackets, long operations) {
+        return combineValues(
+                iface, uid, SET_DEFAULT, tag, rxBytes, rxPackets, txBytes,
+                txPackets, operations);
+    }
+
+    /** @hide */
+    public NetworkStats combineValues(String iface, int uid, int set, int tag,
+            long rxBytes, long rxPackets, long txBytes, long txPackets, long operations) {
+        return combineValues(new Entry(
+                iface, uid, set, tag, rxBytes, rxPackets, txBytes, txPackets, operations));
+    }
+
+    /**
+     * Combine given values with an existing row, or create a new row if
+     * {@link #findIndex(String, int, int, int, int, int, int)} is unable to find match. Can
+     * also be used to subtract values from existing rows. This method mutates the referencing
+     * {@link NetworkStats} object.
+     *
+     * @param entry the {@link Entry} to combine.
+     * @return a reference to this mutated {@link NetworkStats} object.
+     * @hide
+     */
+    public @NonNull NetworkStats combineValues(@NonNull Entry entry) {
+        final int i = findIndex(entry.iface, entry.uid, entry.set, entry.tag, entry.metered,
+                entry.roaming, entry.defaultNetwork);
+        if (i == -1) {
+            // only create new entry when positive contribution
+            insertEntry(entry);
+        } else {
+            rxBytes[i] += entry.rxBytes;
+            rxPackets[i] += entry.rxPackets;
+            txBytes[i] += entry.txBytes;
+            txPackets[i] += entry.txPackets;
+            operations[i] += entry.operations;
+        }
+        return this;
+    }
+
+    /**
+     * Add given values with an existing row, or create a new row if
+     * {@link #findIndex(String, int, int, int, int, int, int)} is unable to find match. Can
+     * also be used to subtract values from existing rows.
+     *
+     * @param entry the {@link Entry} to add.
+     * @return a new constructed {@link NetworkStats} object that contains the result.
+     */
+    public @NonNull NetworkStats addEntry(@NonNull Entry entry) {
+        return this.clone().combineValues(entry);
+    }
+
+    /**
+     * Add the given {@link NetworkStats} objects.
+     *
+     * @return the sum of two objects.
+     */
+    public @NonNull NetworkStats add(@NonNull NetworkStats another) {
+        final NetworkStats ret = this.clone();
+        ret.combineAllValues(another);
+        return ret;
+    }
+
+    /**
+     * Combine all values from another {@link NetworkStats} into this object.
+     * @hide
+     */
+    public void combineAllValues(@NonNull NetworkStats another) {
+        NetworkStats.Entry entry = null;
+        for (int i = 0; i < another.size; i++) {
+            entry = another.getValues(i, entry);
+            combineValues(entry);
+        }
+    }
+
+    /**
+     * Find first stats index that matches the requested parameters.
+     * @hide
+     */
+    public int findIndex(String iface, int uid, int set, int tag, int metered, int roaming,
+            int defaultNetwork) {
+        for (int i = 0; i < size; i++) {
+            if (uid == this.uid[i] && set == this.set[i] && tag == this.tag[i]
+                    && metered == this.metered[i] && roaming == this.roaming[i]
+                    && defaultNetwork == this.defaultNetwork[i]
+                    && Objects.equals(iface, this.iface[i])) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Find first stats index that matches the requested parameters, starting
+     * search around the hinted index as an optimization.
+     * @hide
+     */
+    @VisibleForTesting
+    public int findIndexHinted(String iface, int uid, int set, int tag, int metered, int roaming,
+            int defaultNetwork, int hintIndex) {
+        for (int offset = 0; offset < size; offset++) {
+            final int halfOffset = offset / 2;
+
+            // search outwards from hint index, alternating forward and backward
+            final int i;
+            if (offset % 2 == 0) {
+                i = (hintIndex + halfOffset) % size;
+            } else {
+                i = (size + hintIndex - halfOffset - 1) % size;
+            }
+
+            if (uid == this.uid[i] && set == this.set[i] && tag == this.tag[i]
+                    && metered == this.metered[i] && roaming == this.roaming[i]
+                    && defaultNetwork == this.defaultNetwork[i]
+                    && Objects.equals(iface, this.iface[i])) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Splice in {@link #operations} from the given {@link NetworkStats} based
+     * on matching {@link #uid} and {@link #tag} rows. Ignores {@link #iface},
+     * since operation counts are at data layer.
+     * @hide
+     */
+    public void spliceOperationsFrom(NetworkStats stats) {
+        for (int i = 0; i < size; i++) {
+            final int j = stats.findIndex(iface[i], uid[i], set[i], tag[i], metered[i], roaming[i],
+                    defaultNetwork[i]);
+            if (j == -1) {
+                operations[i] = 0;
+            } else {
+                operations[i] = stats.operations[j];
+            }
+        }
+    }
+
+    /**
+     * Return list of unique interfaces known by this data structure.
+     * @hide
+     */
+    public String[] getUniqueIfaces() {
+        final HashSet<String> ifaces = new HashSet<String>();
+        for (String iface : this.iface) {
+            if (iface != IFACE_ALL) {
+                ifaces.add(iface);
+            }
+        }
+        return ifaces.toArray(new String[ifaces.size()]);
+    }
+
+    /**
+     * Return list of unique UIDs known by this data structure.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public int[] getUniqueUids() {
+        final SparseBooleanArray uids = new SparseBooleanArray();
+        for (int uid : this.uid) {
+            uids.put(uid, true);
+        }
+
+        final int size = uids.size();
+        final int[] result = new int[size];
+        for (int i = 0; i < size; i++) {
+            result[i] = uids.keyAt(i);
+        }
+        return result;
+    }
+
+    /**
+     * Return total bytes represented by this snapshot object, usually used when
+     * checking if a {@link #subtract(NetworkStats)} delta passes a threshold.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public long getTotalBytes() {
+        final Entry entry = getTotal(null);
+        return entry.rxBytes + entry.txBytes;
+    }
+
+    /**
+     * Return total of all fields represented by this snapshot object.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public Entry getTotal(Entry recycle) {
+        return getTotal(recycle, null, UID_ALL, false);
+    }
+
+    /**
+     * Return total of all fields represented by this snapshot object matching
+     * the requested {@link #uid}.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public Entry getTotal(Entry recycle, int limitUid) {
+        return getTotal(recycle, null, limitUid, false);
+    }
+
+    /**
+     * Return total of all fields represented by this snapshot object matching
+     * the requested {@link #iface}.
+     * @hide
+     */
+    public Entry getTotal(Entry recycle, HashSet<String> limitIface) {
+        return getTotal(recycle, limitIface, UID_ALL, false);
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage
+    public Entry getTotalIncludingTags(Entry recycle) {
+        return getTotal(recycle, null, UID_ALL, true);
+    }
+
+    /**
+     * Return total of all fields represented by this snapshot object matching
+     * the requested {@link #iface} and {@link #uid}.
+     *
+     * @param limitIface Set of {@link #iface} to include in total; or {@code
+     *            null} to include all ifaces.
+     */
+    private Entry getTotal(
+            Entry recycle, HashSet<String> limitIface, int limitUid, boolean includeTags) {
+        final Entry entry = recycle != null ? recycle : new Entry();
+
+        entry.iface = IFACE_ALL;
+        entry.uid = limitUid;
+        entry.set = SET_ALL;
+        entry.tag = TAG_NONE;
+        entry.metered = METERED_ALL;
+        entry.roaming = ROAMING_ALL;
+        entry.defaultNetwork = DEFAULT_NETWORK_ALL;
+        entry.rxBytes = 0;
+        entry.rxPackets = 0;
+        entry.txBytes = 0;
+        entry.txPackets = 0;
+        entry.operations = 0;
+
+        for (int i = 0; i < size; i++) {
+            final boolean matchesUid = (limitUid == UID_ALL) || (limitUid == uid[i]);
+            final boolean matchesIface = (limitIface == null) || (limitIface.contains(iface[i]));
+
+            if (matchesUid && matchesIface) {
+                // skip specific tags, since already counted in TAG_NONE
+                if (tag[i] != TAG_NONE && !includeTags) continue;
+
+                entry.rxBytes += rxBytes[i];
+                entry.rxPackets += rxPackets[i];
+                entry.txBytes += txBytes[i];
+                entry.txPackets += txPackets[i];
+                entry.operations += operations[i];
+            }
+        }
+        return entry;
+    }
+
+    /**
+     * Fast path for battery stats.
+     * @hide
+     */
+    public long getTotalPackets() {
+        long total = 0;
+        for (int i = size-1; i >= 0; i--) {
+            total += rxPackets[i] + txPackets[i];
+        }
+        return total;
+    }
+
+    /**
+     * Subtract the given {@link NetworkStats}, effectively leaving the delta
+     * between two snapshots in time. Assumes that statistics rows collect over
+     * time, and that none of them have disappeared. This method does not mutate
+     * the referencing object.
+     *
+     * @return the delta between two objects.
+     */
+    public @NonNull NetworkStats subtract(@NonNull NetworkStats right) {
+        return subtract(this, right, null, null);
+    }
+
+    /**
+     * Subtract the two given {@link NetworkStats} objects, returning the delta
+     * between two snapshots in time. Assumes that statistics rows collect over
+     * time, and that none of them have disappeared.
+     * <p>
+     * If counters have rolled backwards, they are clamped to {@code 0} and
+     * reported to the given {@link NonMonotonicObserver}.
+     * @hide
+     */
+    public static <C> NetworkStats subtract(NetworkStats left, NetworkStats right,
+            NonMonotonicObserver<C> observer, C cookie) {
+        return subtract(left, right, observer, cookie, null);
+    }
+
+    /**
+     * Subtract the two given {@link NetworkStats} objects, returning the delta
+     * between two snapshots in time. Assumes that statistics rows collect over
+     * time, and that none of them have disappeared.
+     * <p>
+     * If counters have rolled backwards, they are clamped to {@code 0} and
+     * reported to the given {@link NonMonotonicObserver}.
+     * <p>
+     * If <var>recycle</var> is supplied, this NetworkStats object will be
+     * reused (and returned) as the result if it is large enough to contain
+     * the data.
+     * @hide
+     */
+    public static <C> NetworkStats subtract(NetworkStats left, NetworkStats right,
+            NonMonotonicObserver<C> observer, C cookie, NetworkStats recycle) {
+        long deltaRealtime = left.elapsedRealtime - right.elapsedRealtime;
+        if (deltaRealtime < 0) {
+            if (observer != null) {
+                observer.foundNonMonotonic(left, -1, right, -1, cookie);
+            }
+            deltaRealtime = 0;
+        }
+
+        // result will have our rows, and elapsed time between snapshots
+        final Entry entry = new Entry();
+        final NetworkStats result;
+        if (recycle != null && recycle.capacity >= left.size) {
+            result = recycle;
+            result.size = 0;
+            result.elapsedRealtime = deltaRealtime;
+        } else {
+            result = new NetworkStats(deltaRealtime, left.size);
+        }
+        for (int i = 0; i < left.size; i++) {
+            entry.iface = left.iface[i];
+            entry.uid = left.uid[i];
+            entry.set = left.set[i];
+            entry.tag = left.tag[i];
+            entry.metered = left.metered[i];
+            entry.roaming = left.roaming[i];
+            entry.defaultNetwork = left.defaultNetwork[i];
+            entry.rxBytes = left.rxBytes[i];
+            entry.rxPackets = left.rxPackets[i];
+            entry.txBytes = left.txBytes[i];
+            entry.txPackets = left.txPackets[i];
+            entry.operations = left.operations[i];
+
+            // find remote row that matches, and subtract
+            final int j = right.findIndexHinted(entry.iface, entry.uid, entry.set, entry.tag,
+                    entry.metered, entry.roaming, entry.defaultNetwork, i);
+            if (j != -1) {
+                // Found matching row, subtract remote value.
+                entry.rxBytes -= right.rxBytes[j];
+                entry.rxPackets -= right.rxPackets[j];
+                entry.txBytes -= right.txBytes[j];
+                entry.txPackets -= right.txPackets[j];
+                entry.operations -= right.operations[j];
+            }
+
+            if (entry.isNegative()) {
+                if (observer != null) {
+                    observer.foundNonMonotonic(left, i, right, j, cookie);
+                }
+                entry.rxBytes = Math.max(entry.rxBytes, 0);
+                entry.rxPackets = Math.max(entry.rxPackets, 0);
+                entry.txBytes = Math.max(entry.txBytes, 0);
+                entry.txPackets = Math.max(entry.txPackets, 0);
+                entry.operations = Math.max(entry.operations, 0);
+            }
+
+            result.insertEntry(entry);
+        }
+
+        return result;
+    }
+
+    /**
+     * Calculate and apply adjustments to captured statistics for 464xlat traffic.
+     *
+     * <p>This mutates stacked traffic stats, to account for IPv4/IPv6 header size difference.
+     *
+     * <p>UID stats, which are only accounted on the stacked interface, need to be increased
+     * by 20 bytes/packet to account for translation overhead.
+     *
+     * <p>The potential additional overhead of 8 bytes/packet for ip fragments is ignored.
+     *
+     * <p>Interface stats need to sum traffic on both stacked and base interface because:
+     *   - eBPF offloaded packets appear only on the stacked interface
+     *   - Non-offloaded ingress packets appear only on the stacked interface
+     *     (due to iptables raw PREROUTING drop rules)
+     *   - Non-offloaded egress packets appear only on the stacked interface
+     *     (due to ignoring traffic from clat daemon by uid match)
+     * (and of course the 20 bytes/packet overhead needs to be applied to stacked interface stats)
+     *
+     * <p>This method will behave fine if {@code stackedIfaces} is an non-synchronized but add-only
+     * {@code ConcurrentHashMap}
+     * @param baseTraffic Traffic on the base interfaces. Will be mutated.
+     * @param stackedTraffic Stats with traffic stacked on top of our ifaces. Will also be mutated.
+     * @param stackedIfaces Mapping ipv6if -> ipv4if interface where traffic is counted on both.
+     * @hide
+     */
+    public static void apply464xlatAdjustments(NetworkStats baseTraffic,
+            NetworkStats stackedTraffic, Map<String, String> stackedIfaces) {
+        // For recycling
+        Entry entry = null;
+        for (int i = 0; i < stackedTraffic.size; i++) {
+            entry = stackedTraffic.getValues(i, entry);
+            if (entry == null) continue;
+            if (entry.iface == null) continue;
+            if (!entry.iface.startsWith(CLATD_INTERFACE_PREFIX)) continue;
+
+            // For 464xlat traffic, per uid stats only counts the bytes of the native IPv4 packet
+            // sent on the stacked interface with prefix "v4-" and drops the IPv6 header size after
+            // unwrapping. To account correctly for on-the-wire traffic, add the 20 additional bytes
+            // difference for all packets (http://b/12249687, http:/b/33681750).
+            //
+            // Note: this doesn't account for LRO/GRO/GSO/TSO (ie. >mtu) traffic correctly, nor
+            // does it correctly account for the 8 extra bytes in the IPv6 fragmentation header.
+            //
+            // While the ebpf code path does try to simulate proper post segmentation packet
+            // counts, we have nothing of the sort of xt_qtaguid stats.
+            entry.rxBytes += entry.rxPackets * IPV4V6_HEADER_DELTA;
+            entry.txBytes += entry.txPackets * IPV4V6_HEADER_DELTA;
+            stackedTraffic.setValues(i, entry);
+        }
+    }
+
+    /**
+     * Calculate and apply adjustments to captured statistics for 464xlat traffic counted twice.
+     *
+     * <p>This mutates the object this method is called on. Equivalent to calling
+     * {@link #apply464xlatAdjustments(NetworkStats, NetworkStats, Map)} with {@code this} as
+     * base and stacked traffic.
+     * @param stackedIfaces Mapping ipv6if -> ipv4if interface where traffic is counted on both.
+     * @hide
+     */
+    public void apply464xlatAdjustments(Map<String, String> stackedIfaces) {
+        apply464xlatAdjustments(this, this, stackedIfaces);
+    }
+
+    /**
+     * Return total statistics grouped by {@link #iface}; doesn't mutate the
+     * original structure.
+     * @hide
+     */
+    public NetworkStats groupedByIface() {
+        final NetworkStats stats = new NetworkStats(elapsedRealtime, 10);
+
+        final Entry entry = new Entry();
+        entry.uid = UID_ALL;
+        entry.set = SET_ALL;
+        entry.tag = TAG_NONE;
+        entry.metered = METERED_ALL;
+        entry.roaming = ROAMING_ALL;
+        entry.defaultNetwork = DEFAULT_NETWORK_ALL;
+        entry.operations = 0L;
+
+        for (int i = 0; i < size; i++) {
+            // skip specific tags, since already counted in TAG_NONE
+            if (tag[i] != TAG_NONE) continue;
+
+            entry.iface = iface[i];
+            entry.rxBytes = rxBytes[i];
+            entry.rxPackets = rxPackets[i];
+            entry.txBytes = txBytes[i];
+            entry.txPackets = txPackets[i];
+            stats.combineValues(entry);
+        }
+
+        return stats;
+    }
+
+    /**
+     * Return total statistics grouped by {@link #uid}; doesn't mutate the
+     * original structure.
+     * @hide
+     */
+    public NetworkStats groupedByUid() {
+        final NetworkStats stats = new NetworkStats(elapsedRealtime, 10);
+
+        final Entry entry = new Entry();
+        entry.iface = IFACE_ALL;
+        entry.set = SET_ALL;
+        entry.tag = TAG_NONE;
+        entry.metered = METERED_ALL;
+        entry.roaming = ROAMING_ALL;
+        entry.defaultNetwork = DEFAULT_NETWORK_ALL;
+
+        for (int i = 0; i < size; i++) {
+            // skip specific tags, since already counted in TAG_NONE
+            if (tag[i] != TAG_NONE) continue;
+
+            entry.uid = uid[i];
+            entry.rxBytes = rxBytes[i];
+            entry.rxPackets = rxPackets[i];
+            entry.txBytes = txBytes[i];
+            entry.txPackets = txPackets[i];
+            entry.operations = operations[i];
+            stats.combineValues(entry);
+        }
+
+        return stats;
+    }
+
+    /**
+     * Remove all rows that match one of specified UIDs.
+     * This mutates the original structure in place.
+     * @hide
+     */
+    public void removeUids(int[] uids) {
+        filter(e -> !CollectionUtils.contains(uids, e.uid));
+    }
+
+    /**
+     * Remove all rows that match one of specified UIDs.
+     * @return the result object.
+     * @hide
+     */
+    @NonNull
+    public NetworkStats removeEmptyEntries() {
+        final NetworkStats ret = this.clone();
+        ret.filter(e -> e.rxBytes != 0 || e.rxPackets != 0 || e.txBytes != 0 || e.txPackets != 0
+                || e.operations != 0);
+        return ret;
+    }
+
+    /**
+     * Only keep entries that match all specified filters.
+     *
+     * <p>This mutates the original structure in place. After this method is called,
+     * size is the number of matching entries, and capacity is the previous capacity.
+     * @param limitUid UID to filter for, or {@link #UID_ALL}.
+     * @param limitIfaces Interfaces to filter for, or {@link #INTERFACES_ALL}.
+     * @param limitTag Tag to filter for, or {@link #TAG_ALL}.
+     * @hide
+     */
+    public void filter(int limitUid, String[] limitIfaces, int limitTag) {
+        if (limitUid == UID_ALL && limitTag == TAG_ALL && limitIfaces == INTERFACES_ALL) {
+            return;
+        }
+        filter(e -> (limitUid == UID_ALL || limitUid == e.uid)
+                && (limitTag == TAG_ALL || limitTag == e.tag)
+                && (limitIfaces == INTERFACES_ALL
+                    || CollectionUtils.contains(limitIfaces, e.iface)));
+    }
+
+    /**
+     * Only keep entries with {@link #set} value less than {@link #SET_DEBUG_START}.
+     *
+     * <p>This mutates the original structure in place.
+     * @hide
+     */
+    public void filterDebugEntries() {
+        filter(e -> e.set < SET_DEBUG_START);
+    }
+
+    private void filter(Predicate<Entry> predicate) {
+        Entry entry = new Entry();
+        int nextOutputEntry = 0;
+        for (int i = 0; i < size; i++) {
+            entry = getValues(i, entry);
+            if (predicate.test(entry)) {
+                if (nextOutputEntry != i) {
+                    setValues(nextOutputEntry, entry);
+                }
+                nextOutputEntry++;
+            }
+        }
+        size = nextOutputEntry;
+    }
+
+    /** @hide */
+    public void dump(String prefix, PrintWriter pw) {
+        pw.print(prefix);
+        pw.print("NetworkStats: elapsedRealtime="); pw.println(elapsedRealtime);
+        for (int i = 0; i < size; i++) {
+            pw.print(prefix);
+            pw.print("  ["); pw.print(i); pw.print("]");
+            pw.print(" iface="); pw.print(iface[i]);
+            pw.print(" uid="); pw.print(uid[i]);
+            pw.print(" set="); pw.print(setToString(set[i]));
+            pw.print(" tag="); pw.print(tagToString(tag[i]));
+            pw.print(" metered="); pw.print(meteredToString(metered[i]));
+            pw.print(" roaming="); pw.print(roamingToString(roaming[i]));
+            pw.print(" defaultNetwork="); pw.print(defaultNetworkToString(defaultNetwork[i]));
+            pw.print(" rxBytes="); pw.print(rxBytes[i]);
+            pw.print(" rxPackets="); pw.print(rxPackets[i]);
+            pw.print(" txBytes="); pw.print(txBytes[i]);
+            pw.print(" txPackets="); pw.print(txPackets[i]);
+            pw.print(" operations="); pw.println(operations[i]);
+        }
+    }
+
+    /**
+     * Return text description of {@link #set} value.
+     * @hide
+     */
+    public static String setToString(int set) {
+        switch (set) {
+            case SET_ALL:
+                return "ALL";
+            case SET_DEFAULT:
+                return "DEFAULT";
+            case SET_FOREGROUND:
+                return "FOREGROUND";
+            case SET_DBG_VPN_IN:
+                return "DBG_VPN_IN";
+            case SET_DBG_VPN_OUT:
+                return "DBG_VPN_OUT";
+            default:
+                return "UNKNOWN";
+        }
+    }
+
+    /**
+     * Return text description of {@link #set} value.
+     * @hide
+     */
+    public static String setToCheckinString(int set) {
+        switch (set) {
+            case SET_ALL:
+                return "all";
+            case SET_DEFAULT:
+                return "def";
+            case SET_FOREGROUND:
+                return "fg";
+            case SET_DBG_VPN_IN:
+                return "vpnin";
+            case SET_DBG_VPN_OUT:
+                return "vpnout";
+            default:
+                return "unk";
+        }
+    }
+
+    /**
+     * @return true if the querySet matches the dataSet.
+     * @hide
+     */
+    public static boolean setMatches(int querySet, int dataSet) {
+        if (querySet == dataSet) {
+            return true;
+        }
+        // SET_ALL matches all non-debugging sets.
+        return querySet == SET_ALL && dataSet < SET_DEBUG_START;
+    }
+
+    /**
+     * Return text description of {@link #tag} value.
+     * @hide
+     */
+    public static String tagToString(int tag) {
+        return "0x" + Integer.toHexString(tag);
+    }
+
+    /**
+     * Return text description of {@link #metered} value.
+     * @hide
+     */
+    public static String meteredToString(int metered) {
+        switch (metered) {
+            case METERED_ALL:
+                return "ALL";
+            case METERED_NO:
+                return "NO";
+            case METERED_YES:
+                return "YES";
+            default:
+                return "UNKNOWN";
+        }
+    }
+
+    /**
+     * Return text description of {@link #roaming} value.
+     * @hide
+     */
+    public static String roamingToString(int roaming) {
+        switch (roaming) {
+            case ROAMING_ALL:
+                return "ALL";
+            case ROAMING_NO:
+                return "NO";
+            case ROAMING_YES:
+                return "YES";
+            default:
+                return "UNKNOWN";
+        }
+    }
+
+    /**
+     * Return text description of {@link #defaultNetwork} value.
+     * @hide
+     */
+    public static String defaultNetworkToString(int defaultNetwork) {
+        switch (defaultNetwork) {
+            case DEFAULT_NETWORK_ALL:
+                return "ALL";
+            case DEFAULT_NETWORK_NO:
+                return "NO";
+            case DEFAULT_NETWORK_YES:
+                return "YES";
+            default:
+                return "UNKNOWN";
+        }
+    }
+
+    /** @hide */
+    @Override
+    public String toString() {
+        final CharArrayWriter writer = new CharArrayWriter();
+        dump("", new PrintWriter(writer));
+        return writer.toString();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final @NonNull Creator<NetworkStats> CREATOR = new Creator<NetworkStats>() {
+        @Override
+        public NetworkStats createFromParcel(Parcel in) {
+            return new NetworkStats(in);
+        }
+
+        @Override
+        public NetworkStats[] newArray(int size) {
+            return new NetworkStats[size];
+        }
+    };
+
+    /** @hide */
+    public interface NonMonotonicObserver<C> {
+        public void foundNonMonotonic(
+                NetworkStats left, int leftIndex, NetworkStats right, int rightIndex, C cookie);
+        public void foundNonMonotonic(
+                NetworkStats stats, int statsIndex, C cookie);
+    }
+
+    /**
+     * VPN accounting. Move some VPN's underlying traffic to other UIDs that use tun0 iface.
+     *
+     * <p>This method should only be called on delta NetworkStats. Do not call this method on a
+     * snapshot {@link NetworkStats} object because the tunUid and/or the underlyingIface may change
+     * over time.
+     *
+     * <p>This method performs adjustments for one active VPN package and one VPN iface at a time.
+     *
+     * @param tunUid uid of the VPN application
+     * @param tunIface iface of the vpn tunnel
+     * @param underlyingIfaces underlying network ifaces used by the VPN application
+     * @hide
+     */
+    public void migrateTun(int tunUid, @NonNull String tunIface,
+            @NonNull List<String> underlyingIfaces) {
+        // Combined usage by all apps using VPN.
+        final Entry tunIfaceTotal = new Entry();
+        // Usage by VPN, grouped by its {@code underlyingIfaces}.
+        final Entry[] perInterfaceTotal = new Entry[underlyingIfaces.size()];
+        // Usage by VPN, summed across all its {@code underlyingIfaces}.
+        final Entry underlyingIfacesTotal = new Entry();
+
+        for (int i = 0; i < perInterfaceTotal.length; i++) {
+            perInterfaceTotal[i] = new Entry();
+        }
+
+        tunAdjustmentInit(tunUid, tunIface, underlyingIfaces, tunIfaceTotal, perInterfaceTotal,
+                underlyingIfacesTotal);
+
+        // If tunIface < underlyingIfacesTotal, it leaves the overhead traffic in the VPN app.
+        // If tunIface > underlyingIfacesTotal, the VPN app doesn't get credit for data compression.
+        // Negative stats should be avoided.
+        final Entry[] moved =
+                addTrafficToApplications(tunUid, tunIface, underlyingIfaces, tunIfaceTotal,
+                        perInterfaceTotal, underlyingIfacesTotal);
+        deductTrafficFromVpnApp(tunUid, underlyingIfaces, moved);
+    }
+
+    /**
+     * Initializes the data used by the migrateTun() method.
+     *
+     * <p>This is the first pass iteration which does the following work:
+     *
+     * <ul>
+     *   <li>Adds up all the traffic through the tunUid's underlyingIfaces (both foreground and
+     *       background).
+     *   <li>Adds up all the traffic through tun0 excluding traffic from the vpn app itself.
+     * </ul>
+     *
+     * @param tunUid uid of the VPN application
+     * @param tunIface iface of the vpn tunnel
+     * @param underlyingIfaces underlying network ifaces used by the VPN application
+     * @param tunIfaceTotal output parameter; combined data usage by all apps using VPN
+     * @param perInterfaceTotal output parameter; data usage by VPN app, grouped by its {@code
+     *     underlyingIfaces}
+     * @param underlyingIfacesTotal output parameter; data usage by VPN, summed across all of its
+     *     {@code underlyingIfaces}
+     */
+    private void tunAdjustmentInit(int tunUid, @NonNull String tunIface,
+            @NonNull List<String> underlyingIfaces, @NonNull Entry tunIfaceTotal,
+            @NonNull Entry[] perInterfaceTotal, @NonNull Entry underlyingIfacesTotal) {
+        final Entry recycle = new Entry();
+        for (int i = 0; i < size; i++) {
+            getValues(i, recycle);
+            if (recycle.uid == UID_ALL) {
+                throw new IllegalStateException(
+                        "Cannot adjust VPN accounting on an iface aggregated NetworkStats.");
+            }
+            if (recycle.set == SET_DBG_VPN_IN || recycle.set == SET_DBG_VPN_OUT) {
+                throw new IllegalStateException(
+                        "Cannot adjust VPN accounting on a NetworkStats containing SET_DBG_VPN_*");
+            }
+            if (recycle.tag != TAG_NONE) {
+                // TODO(b/123666283): Take all tags for tunUid into account.
+                continue;
+            }
+
+            if (tunUid == Process.SYSTEM_UID) {
+                // Kernel-based VPN or VCN, traffic sent by apps on the VPN/VCN network
+                //
+                // Since the data is not UID-accounted on underlying networks, just use VPN/VCN
+                // network usage as ground truth. Encrypted traffic on the underlying networks will
+                // never be processed here because encrypted traffic on the underlying interfaces
+                // is not present in UID stats, and this method is only called on UID stats.
+                if (tunIface.equals(recycle.iface)) {
+                    tunIfaceTotal.add(recycle);
+                    underlyingIfacesTotal.add(recycle);
+
+                    // In steady state, there should always be one network, but edge cases may
+                    // result in the network being null (network lost), and thus no underlying
+                    // ifaces is possible.
+                    if (perInterfaceTotal.length > 0) {
+                        // While platform VPNs and VCNs have exactly one underlying network, that
+                        // network may have multiple interfaces (eg for 464xlat). This layer does
+                        // not have the required information to identify which of the interfaces
+                        // were used. Select "any" of the interfaces. Since overhead is already
+                        // lost, this number is an approximation anyways.
+                        perInterfaceTotal[0].add(recycle);
+                    }
+                }
+            } else if (recycle.uid == tunUid) {
+                // VpnService VPN, traffic sent by the VPN app over underlying networks
+                for (int j = 0; j < underlyingIfaces.size(); j++) {
+                    if (Objects.equals(underlyingIfaces.get(j), recycle.iface)) {
+                        perInterfaceTotal[j].add(recycle);
+                        underlyingIfacesTotal.add(recycle);
+                        break;
+                    }
+                }
+            } else if (tunIface.equals(recycle.iface)) {
+                // VpnService VPN; traffic sent by apps on the VPN network
+                tunIfaceTotal.add(recycle);
+            }
+        }
+    }
+
+    /**
+     * Distributes traffic across apps that are using given {@code tunIface}, and returns the total
+     * traffic that should be moved off of {@code tunUid} grouped by {@code underlyingIfaces}.
+     *
+     * @param tunUid uid of the VPN application
+     * @param tunIface iface of the vpn tunnel
+     * @param underlyingIfaces underlying network ifaces used by the VPN application
+     * @param tunIfaceTotal combined data usage across all apps using {@code tunIface}
+     * @param perInterfaceTotal data usage by VPN app, grouped by its {@code underlyingIfaces}
+     * @param underlyingIfacesTotal data usage by VPN, summed across all of its {@code
+     *     underlyingIfaces}
+     */
+    private Entry[] addTrafficToApplications(int tunUid, @NonNull String tunIface,
+            @NonNull List<String> underlyingIfaces, @NonNull Entry tunIfaceTotal,
+            @NonNull Entry[] perInterfaceTotal, @NonNull Entry underlyingIfacesTotal) {
+        // Traffic that should be moved off of each underlying interface for tunUid (see
+        // deductTrafficFromVpnApp below).
+        final Entry[] moved = new Entry[underlyingIfaces.size()];
+        for (int i = 0; i < underlyingIfaces.size(); i++) {
+            moved[i] = new Entry();
+        }
+
+        final Entry tmpEntry = new Entry();
+        final int origSize = size;
+        for (int i = 0; i < origSize; i++) {
+            if (!Objects.equals(iface[i], tunIface)) {
+                // Consider only entries that go onto the VPN interface.
+                continue;
+            }
+
+            if (uid[i] == tunUid && tunUid != Process.SYSTEM_UID) {
+                // Exclude VPN app from the redistribution, as it can choose to create packet
+                // streams by writing to itself.
+                //
+                // However, for platform VPNs, do not exclude the system's usage of the VPN network,
+                // since it is never local-only, and never double counted
+                continue;
+            }
+            tmpEntry.uid = uid[i];
+            tmpEntry.tag = tag[i];
+            tmpEntry.metered = metered[i];
+            tmpEntry.roaming = roaming[i];
+            tmpEntry.defaultNetwork = defaultNetwork[i];
+
+            // In a first pass, compute this entry's total share of data across all
+            // underlyingIfaces. This is computed on the basis of the share of this entry's usage
+            // over tunIface.
+            // TODO: Consider refactoring first pass into a separate helper method.
+            long totalRxBytes = 0;
+            if (tunIfaceTotal.rxBytes > 0) {
+                // Note - The multiplication below should not overflow since NetworkStatsService
+                // processes this every time device has transmitted/received amount equivalent to
+                // global threshold alert (~ 2MB) across all interfaces.
+                final long rxBytesAcrossUnderlyingIfaces =
+                        multiplySafeByRational(underlyingIfacesTotal.rxBytes,
+                                rxBytes[i], tunIfaceTotal.rxBytes);
+                // app must not be blamed for more than it consumed on tunIface
+                totalRxBytes = Math.min(rxBytes[i], rxBytesAcrossUnderlyingIfaces);
+            }
+            long totalRxPackets = 0;
+            if (tunIfaceTotal.rxPackets > 0) {
+                final long rxPacketsAcrossUnderlyingIfaces =
+                        multiplySafeByRational(underlyingIfacesTotal.rxPackets,
+                                rxPackets[i], tunIfaceTotal.rxPackets);
+                totalRxPackets = Math.min(rxPackets[i], rxPacketsAcrossUnderlyingIfaces);
+            }
+            long totalTxBytes = 0;
+            if (tunIfaceTotal.txBytes > 0) {
+                final long txBytesAcrossUnderlyingIfaces =
+                        multiplySafeByRational(underlyingIfacesTotal.txBytes,
+                                txBytes[i], tunIfaceTotal.txBytes);
+                totalTxBytes = Math.min(txBytes[i], txBytesAcrossUnderlyingIfaces);
+            }
+            long totalTxPackets = 0;
+            if (tunIfaceTotal.txPackets > 0) {
+                final long txPacketsAcrossUnderlyingIfaces =
+                        multiplySafeByRational(underlyingIfacesTotal.txPackets,
+                                txPackets[i], tunIfaceTotal.txPackets);
+                totalTxPackets = Math.min(txPackets[i], txPacketsAcrossUnderlyingIfaces);
+            }
+            long totalOperations = 0;
+            if (tunIfaceTotal.operations > 0) {
+                final long operationsAcrossUnderlyingIfaces =
+                        multiplySafeByRational(underlyingIfacesTotal.operations,
+                                operations[i], tunIfaceTotal.operations);
+                totalOperations = Math.min(operations[i], operationsAcrossUnderlyingIfaces);
+            }
+            // In a second pass, distribute these values across interfaces in the proportion that
+            // each interface represents of the total traffic of the underlying interfaces.
+            for (int j = 0; j < underlyingIfaces.size(); j++) {
+                tmpEntry.iface = underlyingIfaces.get(j);
+                tmpEntry.rxBytes = 0;
+                // Reset 'set' to correct value since it gets updated when adding debug info below.
+                tmpEntry.set = set[i];
+                if (underlyingIfacesTotal.rxBytes > 0) {
+                    tmpEntry.rxBytes =
+                            multiplySafeByRational(totalRxBytes,
+                                    perInterfaceTotal[j].rxBytes,
+                                    underlyingIfacesTotal.rxBytes);
+                }
+                tmpEntry.rxPackets = 0;
+                if (underlyingIfacesTotal.rxPackets > 0) {
+                    tmpEntry.rxPackets =
+                            multiplySafeByRational(totalRxPackets,
+                                    perInterfaceTotal[j].rxPackets,
+                                    underlyingIfacesTotal.rxPackets);
+                }
+                tmpEntry.txBytes = 0;
+                if (underlyingIfacesTotal.txBytes > 0) {
+                    tmpEntry.txBytes =
+                            multiplySafeByRational(totalTxBytes,
+                                    perInterfaceTotal[j].txBytes,
+                                    underlyingIfacesTotal.txBytes);
+                }
+                tmpEntry.txPackets = 0;
+                if (underlyingIfacesTotal.txPackets > 0) {
+                    tmpEntry.txPackets =
+                            multiplySafeByRational(totalTxPackets,
+                                    perInterfaceTotal[j].txPackets,
+                                    underlyingIfacesTotal.txPackets);
+                }
+                tmpEntry.operations = 0;
+                if (underlyingIfacesTotal.operations > 0) {
+                    tmpEntry.operations =
+                            multiplySafeByRational(totalOperations,
+                                    perInterfaceTotal[j].operations,
+                                    underlyingIfacesTotal.operations);
+                }
+                // tmpEntry now contains the migrated data of the i-th entry for the j-th underlying
+                // interface. Add that data usage to this object.
+                combineValues(tmpEntry);
+                if (tag[i] == TAG_NONE) {
+                    // Add the migrated data to moved so it is deducted from the VPN app later.
+                    moved[j].add(tmpEntry);
+                    // Add debug info
+                    tmpEntry.set = SET_DBG_VPN_IN;
+                    combineValues(tmpEntry);
+                }
+            }
+        }
+        return moved;
+    }
+
+    private void deductTrafficFromVpnApp(
+            int tunUid,
+            @NonNull List<String> underlyingIfaces,
+            @NonNull Entry[] moved) {
+        if (tunUid == Process.SYSTEM_UID) {
+            // No traffic recorded on a per-UID basis for in-kernel VPN/VCNs over underlying
+            // networks; thus no traffic to deduct.
+            return;
+        }
+
+        for (int i = 0; i < underlyingIfaces.size(); i++) {
+            moved[i].uid = tunUid;
+            // Add debug info
+            moved[i].set = SET_DBG_VPN_OUT;
+            moved[i].tag = TAG_NONE;
+            moved[i].iface = underlyingIfaces.get(i);
+            moved[i].metered = METERED_ALL;
+            moved[i].roaming = ROAMING_ALL;
+            moved[i].defaultNetwork = DEFAULT_NETWORK_ALL;
+            combineValues(moved[i]);
+
+            // Caveat: if the vpn software uses tag, the total tagged traffic may be greater than
+            // the TAG_NONE traffic.
+            //
+            // Relies on the fact that the underlying traffic only has state ROAMING_NO and
+            // METERED_NO, which should be the case as it comes directly from the /proc file.
+            // We only blend in the roaming data after applying these adjustments, by checking the
+            // NetworkIdentity of the underlying iface.
+            final int idxVpnBackground = findIndex(underlyingIfaces.get(i), tunUid, SET_DEFAULT,
+                            TAG_NONE, METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO);
+            if (idxVpnBackground != -1) {
+                // Note - tunSubtract also updates moved[i]; whatever traffic that's left is removed
+                // from foreground usage.
+                tunSubtract(idxVpnBackground, this, moved[i]);
+            }
+
+            final int idxVpnForeground = findIndex(underlyingIfaces.get(i), tunUid, SET_FOREGROUND,
+                            TAG_NONE, METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO);
+            if (idxVpnForeground != -1) {
+                tunSubtract(idxVpnForeground, this, moved[i]);
+            }
+        }
+    }
+
+    private static void tunSubtract(int i, @NonNull NetworkStats left, @NonNull Entry right) {
+        long rxBytes = Math.min(left.rxBytes[i], right.rxBytes);
+        left.rxBytes[i] -= rxBytes;
+        right.rxBytes -= rxBytes;
+
+        long rxPackets = Math.min(left.rxPackets[i], right.rxPackets);
+        left.rxPackets[i] -= rxPackets;
+        right.rxPackets -= rxPackets;
+
+        long txBytes = Math.min(left.txBytes[i], right.txBytes);
+        left.txBytes[i] -= txBytes;
+        right.txBytes -= txBytes;
+
+        long txPackets = Math.min(left.txPackets[i], right.txPackets);
+        left.txPackets[i] -= txPackets;
+        right.txPackets -= txPackets;
+    }
+}
diff --git a/framework-t/src/android/net/NetworkStatsAccess.java b/framework-t/src/android/net/NetworkStatsAccess.java
new file mode 100644
index 0000000..b64fbdb
--- /dev/null
+++ b/framework-t/src/android/net/NetworkStatsAccess.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2015 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.Manifest.permission.READ_NETWORK_USAGE_HISTORY;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.net.NetworkStats.UID_ALL;
+import static android.net.TrafficStats.UID_REMOVED;
+import static android.net.TrafficStats.UID_TETHERING;
+
+import android.Manifest;
+import android.annotation.IntDef;
+import android.app.AppOpsManager;
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Binder;
+import android.os.Process;
+import android.os.UserHandle;
+import android.telephony.TelephonyManager;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Utility methods for controlling access to network stats APIs.
+ *
+ * @hide
+ */
+public final class NetworkStatsAccess {
+    private NetworkStatsAccess() {}
+
+    /**
+     * Represents an access level for the network usage history and statistics APIs.
+     *
+     * <p>Access levels are in increasing order; that is, it is reasonable to check access by
+     * verifying that the caller's access level is at least the minimum required level.
+     */
+    @IntDef({
+            Level.DEFAULT,
+            Level.USER,
+            Level.DEVICESUMMARY,
+            Level.DEVICE,
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface Level {
+        /**
+         * Default, unprivileged access level.
+         *
+         * <p>Can only access usage for one's own UID.
+         *
+         * <p>Every app will have at least this access level.
+         */
+        int DEFAULT = 0;
+
+        /**
+         * Access level for apps which can access usage for any app running in the same user.
+         *
+         * <p>Granted to:
+         * <ul>
+         * <li>Profile owners.
+         * </ul>
+         */
+        int USER = 1;
+
+        /**
+         * Access level for apps which can access usage summary of device. Device summary includes
+         * usage by apps running in any profiles/users, however this access level does not
+         * allow querying usage of individual apps running in other profiles/users.
+         *
+         * <p>Granted to:
+         * <ul>
+         * <li>Apps with the PACKAGE_USAGE_STATS permission granted. Note that this is an AppOps bit
+         * so it is not necessarily sufficient to declare this in the manifest.
+         * <li>Apps with the (signature/privileged) READ_NETWORK_USAGE_HISTORY permission.
+         * </ul>
+         */
+        int DEVICESUMMARY = 2;
+
+        /**
+         * Access level for apps which can access usage for any app on the device, including apps
+         * running on other users/profiles.
+         *
+         * <p>Granted to:
+         * <ul>
+         * <li>Device owners.
+         * <li>Carrier-privileged applications.
+         * <li>The system UID.
+         * </ul>
+         */
+        int DEVICE = 3;
+    }
+
+    /** Returns the {@link NetworkStatsAccess.Level} for the given caller. */
+    public static @NetworkStatsAccess.Level int checkAccessLevel(
+            Context context, int callingPid, int callingUid, String callingPackage) {
+        final DevicePolicyManager mDpm = context.getSystemService(DevicePolicyManager.class);
+        final TelephonyManager tm = (TelephonyManager)
+                context.getSystemService(Context.TELEPHONY_SERVICE);
+        boolean hasCarrierPrivileges;
+        final long token = Binder.clearCallingIdentity();
+        try {
+            hasCarrierPrivileges = tm != null
+                    && tm.checkCarrierPrivilegesForPackageAnyPhone(callingPackage)
+                            == TelephonyManager.CARRIER_PRIVILEGE_STATUS_HAS_ACCESS;
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+
+        final boolean isDeviceOwner = mDpm != null && mDpm.isDeviceOwnerApp(callingPackage);
+        final int appId = UserHandle.getAppId(callingUid);
+
+        final boolean isNetworkStack = context.checkPermission(
+                android.Manifest.permission.NETWORK_STACK, callingPid, callingUid)
+                == PERMISSION_GRANTED;
+
+        if (hasCarrierPrivileges || isDeviceOwner
+                || appId == Process.SYSTEM_UID || isNetworkStack) {
+            // Carrier-privileged apps and device owners, and the system (including the
+            // network stack) can access data usage for all apps on the device.
+            return NetworkStatsAccess.Level.DEVICE;
+        }
+
+        boolean hasAppOpsPermission = hasAppOpsPermission(context, callingUid, callingPackage);
+        if (hasAppOpsPermission || context.checkCallingOrSelfPermission(
+                READ_NETWORK_USAGE_HISTORY) == PackageManager.PERMISSION_GRANTED) {
+            return NetworkStatsAccess.Level.DEVICESUMMARY;
+        }
+
+        //TODO(b/169395065) Figure out if this flow makes sense in Device Owner mode.
+        boolean isProfileOwner = mDpm != null && (mDpm.isProfileOwnerApp(callingPackage)
+                || mDpm.isDeviceOwnerApp(callingPackage));
+        if (isProfileOwner) {
+            // Apps with the AppOps permission, profile owners, and apps with the privileged
+            // permission can access data usage for all apps in this user/profile.
+            return NetworkStatsAccess.Level.USER;
+        }
+
+        // Everyone else gets default access (only to their own UID).
+        return NetworkStatsAccess.Level.DEFAULT;
+    }
+
+    /**
+     * Returns whether the given caller should be able to access the given UID when the caller has
+     * the given {@link NetworkStatsAccess.Level}.
+     */
+    public static boolean isAccessibleToUser(int uid, int callerUid,
+            @NetworkStatsAccess.Level int accessLevel) {
+        final int userId = UserHandle.getUserHandleForUid(uid).getIdentifier();
+        final int callerUserId = UserHandle.getUserHandleForUid(callerUid).getIdentifier();
+        switch (accessLevel) {
+            case NetworkStatsAccess.Level.DEVICE:
+                // Device-level access - can access usage for any uid.
+                return true;
+            case NetworkStatsAccess.Level.DEVICESUMMARY:
+                // Can access usage for any app running in the same user, along
+                // with some special uids (system, removed, or tethering) and
+                // anonymized uids
+                return uid == android.os.Process.SYSTEM_UID || uid == UID_REMOVED
+                        || uid == UID_TETHERING || uid == UID_ALL
+                        || userId == callerUserId;
+            case NetworkStatsAccess.Level.USER:
+                // User-level access - can access usage for any app running in the same user, along
+                // with some special uids (system, removed, or tethering).
+                return uid == android.os.Process.SYSTEM_UID || uid == UID_REMOVED
+                        || uid == UID_TETHERING
+                        || userId == callerUserId;
+            case NetworkStatsAccess.Level.DEFAULT:
+            default:
+                // Default access level - can only access one's own usage.
+                return uid == callerUid;
+        }
+    }
+
+    private static boolean hasAppOpsPermission(
+            Context context, int callingUid, String callingPackage) {
+        if (callingPackage != null) {
+            AppOpsManager appOps = (AppOpsManager) context.getSystemService(
+                    Context.APP_OPS_SERVICE);
+
+            final int mode = appOps.noteOp(AppOpsManager.OPSTR_GET_USAGE_STATS,
+                    callingUid, callingPackage, null /* attributionTag */, null /* message */);
+            if (mode == AppOpsManager.MODE_DEFAULT) {
+                // The default behavior here is to check if PackageManager has given the app
+                // permission.
+                final int permissionCheck = context.checkCallingPermission(
+                        Manifest.permission.PACKAGE_USAGE_STATS);
+                return permissionCheck == PackageManager.PERMISSION_GRANTED;
+            }
+            return (mode == AppOpsManager.MODE_ALLOWED);
+        }
+        return false;
+    }
+}
diff --git a/framework-t/src/android/net/NetworkStatsCollection.java b/framework-t/src/android/net/NetworkStatsCollection.java
new file mode 100644
index 0000000..e385b33
--- /dev/null
+++ b/framework-t/src/android/net/NetworkStatsCollection.java
@@ -0,0 +1,956 @@
+/*
+ * Copyright (C) 2012 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.net.NetworkStats.DEFAULT_NETWORK_NO;
+import static android.net.NetworkStats.DEFAULT_NETWORK_YES;
+import static android.net.NetworkStats.IFACE_ALL;
+import static android.net.NetworkStats.METERED_NO;
+import static android.net.NetworkStats.METERED_YES;
+import static android.net.NetworkStats.ROAMING_NO;
+import static android.net.NetworkStats.ROAMING_YES;
+import static android.net.NetworkStats.SET_ALL;
+import static android.net.NetworkStats.SET_DEFAULT;
+import static android.net.NetworkStats.TAG_NONE;
+import static android.net.NetworkStats.UID_ALL;
+import static android.net.TrafficStats.UID_REMOVED;
+import static android.text.format.DateUtils.WEEK_IN_MILLIS;
+
+import static com.android.net.module.util.NetworkStatsUtils.multiplySafeByRational;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.net.NetworkStats.State;
+import android.net.NetworkStatsHistory.Entry;
+import android.os.Binder;
+import android.service.NetworkStatsCollectionKeyProto;
+import android.service.NetworkStatsCollectionProto;
+import android.service.NetworkStatsCollectionStatsProto;
+import android.telephony.SubscriptionPlan;
+import android.text.format.DateUtils;
+import android.util.ArrayMap;
+import android.util.AtomicFile;
+import android.util.IndentingPrintWriter;
+import android.util.Log;
+import android.util.Range;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.FileRotator;
+import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.NetworkStatsUtils;
+
+import libcore.io.IoUtils;
+
+import java.io.BufferedInputStream;
+import java.io.DataInput;
+import java.io.DataInputStream;
+import java.io.DataOutput;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.net.ProtocolException;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Collection of {@link NetworkStatsHistory}, stored based on combined key of
+ * {@link NetworkIdentitySet}, UID, set, and tag. Knows how to persist itself.
+ *
+ * @hide
+ */
+@SystemApi(client = MODULE_LIBRARIES)
+public class NetworkStatsCollection implements FileRotator.Reader, FileRotator.Writer {
+    private static final String TAG = NetworkStatsCollection.class.getSimpleName();
+    /** File header magic number: "ANET" */
+    private static final int FILE_MAGIC = 0x414E4554;
+
+    private static final int VERSION_NETWORK_INIT = 1;
+
+    private static final int VERSION_UID_INIT = 1;
+    private static final int VERSION_UID_WITH_IDENT = 2;
+    private static final int VERSION_UID_WITH_TAG = 3;
+    private static final int VERSION_UID_WITH_SET = 4;
+
+    private static final int VERSION_UNIFIED_INIT = 16;
+
+    private ArrayMap<Key, NetworkStatsHistory> mStats = new ArrayMap<>();
+
+    private final long mBucketDurationMillis;
+
+    private long mStartMillis;
+    private long mEndMillis;
+    private long mTotalBytes;
+    private boolean mDirty;
+
+    /**
+     * Construct a {@link NetworkStatsCollection} object.
+     *
+     * @param bucketDuration duration of the buckets in this object, in milliseconds.
+     * @hide
+     */
+    public NetworkStatsCollection(long bucketDurationMillis) {
+        mBucketDurationMillis = bucketDurationMillis;
+        reset();
+    }
+
+    /** @hide */
+    public void clear() {
+        reset();
+    }
+
+    /** @hide */
+    public void reset() {
+        mStats.clear();
+        mStartMillis = Long.MAX_VALUE;
+        mEndMillis = Long.MIN_VALUE;
+        mTotalBytes = 0;
+        mDirty = false;
+    }
+
+    /** @hide */
+    public long getStartMillis() {
+        return mStartMillis;
+    }
+
+    /**
+     * Return first atomic bucket in this collection, which is more conservative
+     * than {@link #mStartMillis}.
+     * @hide
+     */
+    public long getFirstAtomicBucketMillis() {
+        if (mStartMillis == Long.MAX_VALUE) {
+            return Long.MAX_VALUE;
+        } else {
+            return mStartMillis + mBucketDurationMillis;
+        }
+    }
+
+    /** @hide */
+    public long getEndMillis() {
+        return mEndMillis;
+    }
+
+    /** @hide */
+    public long getTotalBytes() {
+        return mTotalBytes;
+    }
+
+    /** @hide */
+    public boolean isDirty() {
+        return mDirty;
+    }
+
+    /** @hide */
+    public void clearDirty() {
+        mDirty = false;
+    }
+
+    /** @hide */
+    public boolean isEmpty() {
+        return mStartMillis == Long.MAX_VALUE && mEndMillis == Long.MIN_VALUE;
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public long roundUp(long time) {
+        if (time == Long.MIN_VALUE || time == Long.MAX_VALUE
+                || time == SubscriptionPlan.TIME_UNKNOWN) {
+            return time;
+        } else {
+            final long mod = time % mBucketDurationMillis;
+            if (mod > 0) {
+                time -= mod;
+                time += mBucketDurationMillis;
+            }
+            return time;
+        }
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public long roundDown(long time) {
+        if (time == Long.MIN_VALUE || time == Long.MAX_VALUE
+                || time == SubscriptionPlan.TIME_UNKNOWN) {
+            return time;
+        } else {
+            final long mod = time % mBucketDurationMillis;
+            if (mod > 0) {
+                time -= mod;
+            }
+            return time;
+        }
+    }
+
+    /** @hide */
+    public int[] getRelevantUids(@NetworkStatsAccess.Level int accessLevel) {
+        return getRelevantUids(accessLevel, Binder.getCallingUid());
+    }
+
+    /** @hide */
+    public int[] getRelevantUids(@NetworkStatsAccess.Level int accessLevel,
+                final int callerUid) {
+        final ArrayList<Integer> uids = new ArrayList<>();
+        for (int i = 0; i < mStats.size(); i++) {
+            final Key key = mStats.keyAt(i);
+            if (NetworkStatsAccess.isAccessibleToUser(key.uid, callerUid, accessLevel)) {
+                int j = Collections.binarySearch(uids, new Integer(key.uid));
+
+                if (j < 0) {
+                    j = ~j;
+                    uids.add(j, key.uid);
+                }
+            }
+        }
+        return CollectionUtils.toIntArray(uids);
+    }
+
+    /**
+     * Combine all {@link NetworkStatsHistory} in this collection which match
+     * the requested parameters.
+     * @hide
+     */
+    public NetworkStatsHistory getHistory(NetworkTemplate template, SubscriptionPlan augmentPlan,
+            int uid, int set, int tag, int fields, long start, long end,
+            @NetworkStatsAccess.Level int accessLevel, int callerUid) {
+        if (!NetworkStatsAccess.isAccessibleToUser(uid, callerUid, accessLevel)) {
+            throw new SecurityException("Network stats history of uid " + uid
+                    + " is forbidden for caller " + callerUid);
+        }
+
+        // 180 days of history should be enough for anyone; if we end up needing
+        // more, we'll dynamically grow the history object.
+        final int bucketEstimate = (int) NetworkStatsUtils.constrain(
+                ((end - start) / mBucketDurationMillis), 0,
+                (180 * DateUtils.DAY_IN_MILLIS) / mBucketDurationMillis);
+        final NetworkStatsHistory combined = new NetworkStatsHistory(
+                mBucketDurationMillis, bucketEstimate, fields);
+
+        // shortcut when we know stats will be empty
+        if (start == end) return combined;
+
+        // Figure out the window of time that we should be augmenting (if any)
+        long augmentStart = SubscriptionPlan.TIME_UNKNOWN;
+        long augmentEnd = (augmentPlan != null) ? augmentPlan.getDataUsageTime()
+                : SubscriptionPlan.TIME_UNKNOWN;
+        // And if augmenting, we might need to collect more data to adjust with
+        long collectStart = start;
+        long collectEnd = end;
+
+        if (augmentEnd != SubscriptionPlan.TIME_UNKNOWN) {
+            final Iterator<Range<ZonedDateTime>> it = augmentPlan.cycleIterator();
+            while (it.hasNext()) {
+                final Range<ZonedDateTime> cycle = it.next();
+                final long cycleStart = cycle.getLower().toInstant().toEpochMilli();
+                final long cycleEnd = cycle.getUpper().toInstant().toEpochMilli();
+                if (cycleStart <= augmentEnd && augmentEnd < cycleEnd) {
+                    augmentStart = cycleStart;
+                    collectStart = Long.min(collectStart, augmentStart);
+                    collectEnd = Long.max(collectEnd, augmentEnd);
+                    break;
+                }
+            }
+        }
+
+        if (augmentStart != SubscriptionPlan.TIME_UNKNOWN) {
+            // Shrink augmentation window so we don't risk undercounting.
+            augmentStart = roundUp(augmentStart);
+            augmentEnd = roundDown(augmentEnd);
+            // Grow collection window so we get all the stats needed.
+            collectStart = roundDown(collectStart);
+            collectEnd = roundUp(collectEnd);
+        }
+
+        for (int i = 0; i < mStats.size(); i++) {
+            final Key key = mStats.keyAt(i);
+            if (key.uid == uid && NetworkStats.setMatches(set, key.set) && key.tag == tag
+                    && templateMatches(template, key.ident)) {
+                final NetworkStatsHistory value = mStats.valueAt(i);
+                combined.recordHistory(value, collectStart, collectEnd);
+            }
+        }
+
+        if (augmentStart != SubscriptionPlan.TIME_UNKNOWN) {
+            final NetworkStatsHistory.Entry entry = combined.getValues(
+                    augmentStart, augmentEnd, null);
+
+            // If we don't have any recorded data for this time period, give
+            // ourselves something to scale with.
+            if (entry.rxBytes == 0 || entry.txBytes == 0) {
+                combined.recordData(augmentStart, augmentEnd,
+                        new NetworkStats.Entry(1, 0, 1, 0, 0));
+                combined.getValues(augmentStart, augmentEnd, entry);
+            }
+
+            final long rawBytes = (entry.rxBytes + entry.txBytes) == 0 ? 1 :
+                    (entry.rxBytes + entry.txBytes);
+            final long rawRxBytes = entry.rxBytes == 0 ? 1 : entry.rxBytes;
+            final long rawTxBytes = entry.txBytes == 0 ? 1 : entry.txBytes;
+            final long targetBytes = augmentPlan.getDataUsageBytes();
+
+            final long targetRxBytes = multiplySafeByRational(targetBytes, rawRxBytes, rawBytes);
+            final long targetTxBytes = multiplySafeByRational(targetBytes, rawTxBytes, rawBytes);
+
+
+            // Scale all matching buckets to reach anchor target
+            final long beforeTotal = combined.getTotalBytes();
+            for (int i = 0; i < combined.size(); i++) {
+                combined.getValues(i, entry);
+                if (entry.bucketStart >= augmentStart
+                        && entry.bucketStart + entry.bucketDuration <= augmentEnd) {
+                    entry.rxBytes = multiplySafeByRational(
+                            targetRxBytes, entry.rxBytes, rawRxBytes);
+                    entry.txBytes = multiplySafeByRational(
+                            targetTxBytes, entry.txBytes, rawTxBytes);
+                    // We purposefully clear out packet counters to indicate
+                    // that this data has been augmented.
+                    entry.rxPackets = 0;
+                    entry.txPackets = 0;
+                    combined.setValues(i, entry);
+                }
+            }
+
+            final long deltaTotal = combined.getTotalBytes() - beforeTotal;
+            if (deltaTotal != 0) {
+                Log.d(TAG, "Augmented network usage by " + deltaTotal + " bytes");
+            }
+
+            // Finally we can slice data as originally requested
+            final NetworkStatsHistory sliced = new NetworkStatsHistory(
+                    mBucketDurationMillis, bucketEstimate, fields);
+            sliced.recordHistory(combined, start, end);
+            return sliced;
+        } else {
+            return combined;
+        }
+    }
+
+    /**
+     * Summarize all {@link NetworkStatsHistory} in this collection which match
+     * the requested parameters across the requested range.
+     *
+     * @param template - a predicate for filtering netstats.
+     * @param start - start of the range, timestamp in milliseconds since the epoch.
+     * @param end - end of the range, timestamp in milliseconds since the epoch.
+     * @param accessLevel - caller access level.
+     * @param callerUid - caller UID.
+     * @hide
+     */
+    public NetworkStats getSummary(NetworkTemplate template, long start, long end,
+            @NetworkStatsAccess.Level int accessLevel, int callerUid) {
+        final long now = System.currentTimeMillis();
+
+        final NetworkStats stats = new NetworkStats(end - start, 24);
+
+        // shortcut when we know stats will be empty
+        if (start == end) return stats;
+
+        final NetworkStats.Entry entry = new NetworkStats.Entry();
+        NetworkStatsHistory.Entry historyEntry = null;
+
+        for (int i = 0; i < mStats.size(); i++) {
+            final Key key = mStats.keyAt(i);
+            if (templateMatches(template, key.ident)
+                    && NetworkStatsAccess.isAccessibleToUser(key.uid, callerUid, accessLevel)
+                    && key.set < NetworkStats.SET_DEBUG_START) {
+                final NetworkStatsHistory value = mStats.valueAt(i);
+                historyEntry = value.getValues(start, end, now, historyEntry);
+
+                entry.iface = IFACE_ALL;
+                entry.uid = key.uid;
+                entry.set = key.set;
+                entry.tag = key.tag;
+                entry.defaultNetwork = key.ident.areAllMembersOnDefaultNetwork()
+                        ? DEFAULT_NETWORK_YES : DEFAULT_NETWORK_NO;
+                entry.metered = key.ident.isAnyMemberMetered() ? METERED_YES : METERED_NO;
+                entry.roaming = key.ident.isAnyMemberRoaming() ? ROAMING_YES : ROAMING_NO;
+                entry.rxBytes = historyEntry.rxBytes;
+                entry.rxPackets = historyEntry.rxPackets;
+                entry.txBytes = historyEntry.txBytes;
+                entry.txPackets = historyEntry.txPackets;
+                entry.operations = historyEntry.operations;
+
+                if (!entry.isEmpty()) {
+                    stats.combineValues(entry);
+                }
+            }
+        }
+
+        return stats;
+    }
+
+    /**
+     * Record given {@link android.net.NetworkStats.Entry} into this collection.
+     * @hide
+     */
+    public void recordData(NetworkIdentitySet ident, int uid, int set, int tag, long start,
+            long end, NetworkStats.Entry entry) {
+        final NetworkStatsHistory history = findOrCreateHistory(ident, uid, set, tag);
+        history.recordData(start, end, entry);
+        noteRecordedHistory(history.getStart(), history.getEnd(), entry.rxBytes + entry.txBytes);
+    }
+
+    /**
+     * Record given {@link NetworkStatsHistory} into this collection.
+     *
+     * @hide
+     */
+    public void recordHistory(@NonNull Key key, @NonNull NetworkStatsHistory history) {
+        Objects.requireNonNull(key);
+        Objects.requireNonNull(history);
+        if (history.size() == 0) return;
+        noteRecordedHistory(history.getStart(), history.getEnd(), history.getTotalBytes());
+
+        NetworkStatsHistory target = mStats.get(key);
+        if (target == null) {
+            target = new NetworkStatsHistory(history.getBucketDuration());
+            mStats.put(key, target);
+        }
+        target.recordEntireHistory(history);
+    }
+
+    /**
+     * Record all {@link NetworkStatsHistory} contained in the given collection
+     * into this collection.
+     *
+     * @hide
+     */
+    public void recordCollection(@NonNull NetworkStatsCollection another) {
+        Objects.requireNonNull(another);
+        for (int i = 0; i < another.mStats.size(); i++) {
+            final Key key = another.mStats.keyAt(i);
+            final NetworkStatsHistory value = another.mStats.valueAt(i);
+            recordHistory(key, value);
+        }
+    }
+
+    private NetworkStatsHistory findOrCreateHistory(
+            NetworkIdentitySet ident, int uid, int set, int tag) {
+        final Key key = new Key(ident, uid, set, tag);
+        final NetworkStatsHistory existing = mStats.get(key);
+
+        // update when no existing, or when bucket duration changed
+        NetworkStatsHistory updated = null;
+        if (existing == null) {
+            updated = new NetworkStatsHistory(mBucketDurationMillis, 10);
+        } else if (existing.getBucketDuration() != mBucketDurationMillis) {
+            updated = new NetworkStatsHistory(existing, mBucketDurationMillis);
+        }
+
+        if (updated != null) {
+            mStats.put(key, updated);
+            return updated;
+        } else {
+            return existing;
+        }
+    }
+
+    /** @hide */
+    @Override
+    public void read(InputStream in) throws IOException {
+        read((DataInput) new DataInputStream(in));
+    }
+
+    private void read(DataInput in) throws IOException {
+        // verify file magic header intact
+        final int magic = in.readInt();
+        if (magic != FILE_MAGIC) {
+            throw new ProtocolException("unexpected magic: " + magic);
+        }
+
+        final int version = in.readInt();
+        switch (version) {
+            case VERSION_UNIFIED_INIT: {
+                // uid := size *(NetworkIdentitySet size *(uid set tag NetworkStatsHistory))
+                final int identSize = in.readInt();
+                for (int i = 0; i < identSize; i++) {
+                    final NetworkIdentitySet ident = new NetworkIdentitySet(in);
+
+                    final int size = in.readInt();
+                    for (int j = 0; j < size; j++) {
+                        final int uid = in.readInt();
+                        final int set = in.readInt();
+                        final int tag = in.readInt();
+
+                        final Key key = new Key(ident, uid, set, tag);
+                        final NetworkStatsHistory history = new NetworkStatsHistory(in);
+                        recordHistory(key, history);
+                    }
+                }
+                break;
+            }
+            default: {
+                throw new ProtocolException("unexpected version: " + version);
+            }
+        }
+    }
+
+    /** @hide */
+    @Override
+    public void write(OutputStream out) throws IOException {
+        write((DataOutput) new DataOutputStream(out));
+        out.flush();
+    }
+
+    private void write(DataOutput out) throws IOException {
+        // cluster key lists grouped by ident
+        final HashMap<NetworkIdentitySet, ArrayList<Key>> keysByIdent = new HashMap<>();
+        for (Key key : mStats.keySet()) {
+            ArrayList<Key> keys = keysByIdent.get(key.ident);
+            if (keys == null) {
+                keys = new ArrayList<>();
+                keysByIdent.put(key.ident, keys);
+            }
+            keys.add(key);
+        }
+
+        out.writeInt(FILE_MAGIC);
+        out.writeInt(VERSION_UNIFIED_INIT);
+
+        out.writeInt(keysByIdent.size());
+        for (NetworkIdentitySet ident : keysByIdent.keySet()) {
+            final ArrayList<Key> keys = keysByIdent.get(ident);
+            ident.writeToStream(out);
+
+            out.writeInt(keys.size());
+            for (Key key : keys) {
+                final NetworkStatsHistory history = mStats.get(key);
+                out.writeInt(key.uid);
+                out.writeInt(key.set);
+                out.writeInt(key.tag);
+                history.writeToStream(out);
+            }
+        }
+    }
+
+    /**
+     * Read legacy network summary statistics file format into the collection,
+     * See {@code NetworkStatsService#maybeUpgradeLegacyStatsLocked}.
+     *
+     * @deprecated
+     * @hide
+     */
+    @Deprecated
+    public void readLegacyNetwork(File file) throws IOException {
+        final AtomicFile inputFile = new AtomicFile(file);
+
+        DataInputStream in = null;
+        try {
+            in = new DataInputStream(new BufferedInputStream(inputFile.openRead()));
+
+            // verify file magic header intact
+            final int magic = in.readInt();
+            if (magic != FILE_MAGIC) {
+                throw new ProtocolException("unexpected magic: " + magic);
+            }
+
+            final int version = in.readInt();
+            switch (version) {
+                case VERSION_NETWORK_INIT: {
+                    // network := size *(NetworkIdentitySet NetworkStatsHistory)
+                    final int size = in.readInt();
+                    for (int i = 0; i < size; i++) {
+                        final NetworkIdentitySet ident = new NetworkIdentitySet(in);
+                        final NetworkStatsHistory history = new NetworkStatsHistory(in);
+
+                        final Key key = new Key(ident, UID_ALL, SET_ALL, TAG_NONE);
+                        recordHistory(key, history);
+                    }
+                    break;
+                }
+                default: {
+                    throw new ProtocolException("unexpected version: " + version);
+                }
+            }
+        } catch (FileNotFoundException e) {
+            // missing stats is okay, probably first boot
+        } finally {
+            IoUtils.closeQuietly(in);
+        }
+    }
+
+    /**
+     * Read legacy Uid statistics file format into the collection,
+     * See {@code NetworkStatsService#maybeUpgradeLegacyStatsLocked}.
+     *
+     * @deprecated
+     * @hide
+     */
+    @Deprecated
+    public void readLegacyUid(File file, boolean onlyTags) throws IOException {
+        final AtomicFile inputFile = new AtomicFile(file);
+
+        DataInputStream in = null;
+        try {
+            in = new DataInputStream(new BufferedInputStream(inputFile.openRead()));
+
+            // verify file magic header intact
+            final int magic = in.readInt();
+            if (magic != FILE_MAGIC) {
+                throw new ProtocolException("unexpected magic: " + magic);
+            }
+
+            final int version = in.readInt();
+            switch (version) {
+                case VERSION_UID_INIT: {
+                    // uid := size *(UID NetworkStatsHistory)
+
+                    // drop this data version, since we don't have a good
+                    // mapping into NetworkIdentitySet.
+                    break;
+                }
+                case VERSION_UID_WITH_IDENT: {
+                    // uid := size *(NetworkIdentitySet size *(UID NetworkStatsHistory))
+
+                    // drop this data version, since this version only existed
+                    // for a short time.
+                    break;
+                }
+                case VERSION_UID_WITH_TAG:
+                case VERSION_UID_WITH_SET: {
+                    // uid := size *(NetworkIdentitySet size *(uid set tag NetworkStatsHistory))
+                    final int identSize = in.readInt();
+                    for (int i = 0; i < identSize; i++) {
+                        final NetworkIdentitySet ident = new NetworkIdentitySet(in);
+
+                        final int size = in.readInt();
+                        for (int j = 0; j < size; j++) {
+                            final int uid = in.readInt();
+                            final int set = (version >= VERSION_UID_WITH_SET) ? in.readInt()
+                                    : SET_DEFAULT;
+                            final int tag = in.readInt();
+
+                            final Key key = new Key(ident, uid, set, tag);
+                            final NetworkStatsHistory history = new NetworkStatsHistory(in);
+
+                            if ((tag == TAG_NONE) != onlyTags) {
+                                recordHistory(key, history);
+                            }
+                        }
+                    }
+                    break;
+                }
+                default: {
+                    throw new ProtocolException("unexpected version: " + version);
+                }
+            }
+        } catch (FileNotFoundException e) {
+            // missing stats is okay, probably first boot
+        } finally {
+            IoUtils.closeQuietly(in);
+        }
+    }
+
+    /**
+     * Remove any {@link NetworkStatsHistory} attributed to the requested UID,
+     * moving any {@link NetworkStats#TAG_NONE} series to
+     * {@link TrafficStats#UID_REMOVED}.
+     * @hide
+     */
+    public void removeUids(int[] uids) {
+        final ArrayList<Key> knownKeys = new ArrayList<>();
+        knownKeys.addAll(mStats.keySet());
+
+        // migrate all UID stats into special "removed" bucket
+        for (Key key : knownKeys) {
+            if (CollectionUtils.contains(uids, key.uid)) {
+                // only migrate combined TAG_NONE history
+                if (key.tag == TAG_NONE) {
+                    final NetworkStatsHistory uidHistory = mStats.get(key);
+                    final NetworkStatsHistory removedHistory = findOrCreateHistory(
+                            key.ident, UID_REMOVED, SET_DEFAULT, TAG_NONE);
+                    removedHistory.recordEntireHistory(uidHistory);
+                }
+                mStats.remove(key);
+                mDirty = true;
+            }
+        }
+    }
+
+    private void noteRecordedHistory(long startMillis, long endMillis, long totalBytes) {
+        if (startMillis < mStartMillis) mStartMillis = startMillis;
+        if (endMillis > mEndMillis) mEndMillis = endMillis;
+        mTotalBytes += totalBytes;
+        mDirty = true;
+    }
+
+    private int estimateBuckets() {
+        return (int) (Math.min(mEndMillis - mStartMillis, WEEK_IN_MILLIS * 5)
+                / mBucketDurationMillis);
+    }
+
+    private ArrayList<Key> getSortedKeys() {
+        final ArrayList<Key> keys = new ArrayList<>();
+        keys.addAll(mStats.keySet());
+        Collections.sort(keys, (left, right) -> Key.compare(left, right));
+        return keys;
+    }
+
+    /** @hide */
+    public void dump(IndentingPrintWriter pw) {
+        for (Key key : getSortedKeys()) {
+            pw.print("ident="); pw.print(key.ident.toString());
+            pw.print(" uid="); pw.print(key.uid);
+            pw.print(" set="); pw.print(NetworkStats.setToString(key.set));
+            pw.print(" tag="); pw.println(NetworkStats.tagToString(key.tag));
+
+            final NetworkStatsHistory history = mStats.get(key);
+            pw.increaseIndent();
+            history.dump(pw, true);
+            pw.decreaseIndent();
+        }
+    }
+
+    /** @hide */
+    public void dumpDebug(ProtoOutputStream proto, long tag) {
+        final long start = proto.start(tag);
+
+        for (Key key : getSortedKeys()) {
+            final long startStats = proto.start(NetworkStatsCollectionProto.STATS);
+
+            // Key
+            final long startKey = proto.start(NetworkStatsCollectionStatsProto.KEY);
+            key.ident.dumpDebug(proto, NetworkStatsCollectionKeyProto.IDENTITY);
+            proto.write(NetworkStatsCollectionKeyProto.UID, key.uid);
+            proto.write(NetworkStatsCollectionKeyProto.SET, key.set);
+            proto.write(NetworkStatsCollectionKeyProto.TAG, key.tag);
+            proto.end(startKey);
+
+            // Value
+            final NetworkStatsHistory history = mStats.get(key);
+            history.dumpDebug(proto, NetworkStatsCollectionStatsProto.HISTORY);
+            proto.end(startStats);
+        }
+
+        proto.end(start);
+    }
+
+    /** @hide */
+    public void dumpCheckin(PrintWriter pw, long start, long end) {
+        dumpCheckin(pw, start, end, NetworkTemplate.buildTemplateMobileWildcard(), "cell");
+        dumpCheckin(pw, start, end, NetworkTemplate.buildTemplateWifiWildcard(), "wifi");
+        dumpCheckin(pw, start, end, NetworkTemplate.buildTemplateEthernet(), "eth");
+        dumpCheckin(pw, start, end, NetworkTemplate.buildTemplateBluetooth(), "bt");
+    }
+
+    /**
+     * Dump all contained stats that match requested parameters, but group
+     * together all matching {@link NetworkTemplate} under a single prefix.
+     */
+    private void dumpCheckin(PrintWriter pw, long start, long end, NetworkTemplate groupTemplate,
+            String groupPrefix) {
+        final ArrayMap<Key, NetworkStatsHistory> grouped = new ArrayMap<>();
+
+        // Walk through all history, grouping by matching network templates
+        for (int i = 0; i < mStats.size(); i++) {
+            final Key key = mStats.keyAt(i);
+            final NetworkStatsHistory value = mStats.valueAt(i);
+
+            if (!templateMatches(groupTemplate, key.ident)) continue;
+            if (key.set >= NetworkStats.SET_DEBUG_START) continue;
+
+            final Key groupKey = new Key(null, key.uid, key.set, key.tag);
+            NetworkStatsHistory groupHistory = grouped.get(groupKey);
+            if (groupHistory == null) {
+                groupHistory = new NetworkStatsHistory(value.getBucketDuration());
+                grouped.put(groupKey, groupHistory);
+            }
+            groupHistory.recordHistory(value, start, end);
+        }
+
+        for (int i = 0; i < grouped.size(); i++) {
+            final Key key = grouped.keyAt(i);
+            final NetworkStatsHistory value = grouped.valueAt(i);
+
+            if (value.size() == 0) continue;
+
+            pw.print("c,");
+            pw.print(groupPrefix); pw.print(',');
+            pw.print(key.uid); pw.print(',');
+            pw.print(NetworkStats.setToCheckinString(key.set)); pw.print(',');
+            pw.print(key.tag);
+            pw.println();
+
+            value.dumpCheckin(pw);
+        }
+    }
+
+    /**
+     * Test if given {@link NetworkTemplate} matches any {@link NetworkIdentity}
+     * in the given {@link NetworkIdentitySet}.
+     */
+    private static boolean templateMatches(NetworkTemplate template, NetworkIdentitySet identSet) {
+        for (NetworkIdentity ident : identSet) {
+            if (template.matches(ident)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Get the all historical stats of the collection {@link NetworkStatsCollection}.
+     *
+     * @return All {@link NetworkStatsHistory} in this collection.
+     */
+    @NonNull
+    public Map<Key, NetworkStatsHistory> getEntries() {
+        return new ArrayMap(mStats);
+    }
+
+    /**
+     * Builder class for {@link NetworkStatsCollection}.
+     */
+    public static final class Builder {
+        private final long mBucketDurationMillis;
+        private final ArrayMap<Key, NetworkStatsHistory> mEntries = new ArrayMap<>();
+
+        /**
+         * Creates a new Builder with given bucket duration.
+         *
+         * @param bucketDuration Duration of the buckets of the object, in milliseconds.
+         */
+        public Builder(long bucketDurationMillis) {
+            mBucketDurationMillis = bucketDurationMillis;
+        }
+
+        /**
+         * Add association of the history with the specified key in this map.
+         *
+         * @param key The object used to identify a network, see {@link Key}.
+         * @param history {@link NetworkStatsHistory} instance associated to the given {@link Key}.
+         * @return The builder object.
+         */
+        @NonNull
+        public NetworkStatsCollection.Builder addEntry(@NonNull Key key,
+                @NonNull NetworkStatsHistory history) {
+            Objects.requireNonNull(key);
+            Objects.requireNonNull(history);
+            final List<Entry> historyEntries = history.getEntries();
+
+            final NetworkStatsHistory.Builder historyBuilder =
+                    new NetworkStatsHistory.Builder(mBucketDurationMillis, historyEntries.size());
+            for (Entry entry : historyEntries) {
+                historyBuilder.addEntry(entry);
+            }
+
+            mEntries.put(key, historyBuilder.build());
+            return this;
+        }
+
+        /**
+         * Builds the instance of the {@link NetworkStatsCollection}.
+         *
+         * @return the built instance of {@link NetworkStatsCollection}.
+         */
+        @NonNull
+        public NetworkStatsCollection build() {
+            final NetworkStatsCollection collection =
+                    new NetworkStatsCollection(mBucketDurationMillis);
+            for (int i = 0; i < mEntries.size(); i++) {
+                collection.recordHistory(mEntries.keyAt(i), mEntries.valueAt(i));
+            }
+            return collection;
+        }
+    }
+
+    /**
+     * the identifier that associate with the {@link NetworkStatsHistory} object to identify
+     * a certain record in the {@link NetworkStatsCollection} object.
+     */
+    public static final class Key {
+        /** @hide */
+        public final NetworkIdentitySet ident;
+        /** @hide */
+        public final int uid;
+        /** @hide */
+        public final int set;
+        /** @hide */
+        public final int tag;
+
+        private final int mHashCode;
+
+        /**
+         * Construct a {@link Key} object.
+         *
+         * @param ident a Set of {@link NetworkIdentity} that associated with the record.
+         * @param uid Uid of the record.
+         * @param set Set of the record, see {@code NetworkStats#SET_*}.
+         * @param tag Tag of the record, see {@link TrafficStats#setThreadStatsTag(int)}.
+         */
+        public Key(@NonNull Set<NetworkIdentity> ident, int uid, @State int set, int tag) {
+            this(new NetworkIdentitySet(Objects.requireNonNull(ident)), uid, set, tag);
+        }
+
+        /** @hide */
+        public Key(@NonNull NetworkIdentitySet ident, int uid, int set, int tag) {
+            this.ident = Objects.requireNonNull(ident);
+            this.uid = uid;
+            this.set = set;
+            this.tag = tag;
+            mHashCode = Objects.hash(ident, uid, set, tag);
+        }
+
+        @Override
+        public int hashCode() {
+            return mHashCode;
+        }
+
+        @Override
+        public boolean equals(@Nullable Object obj) {
+            if (obj instanceof Key) {
+                final Key key = (Key) obj;
+                return uid == key.uid && set == key.set && tag == key.tag
+                        && Objects.equals(ident, key.ident);
+            }
+            return false;
+        }
+
+        /** @hide */
+        public static int compare(@NonNull Key left, @NonNull Key right) {
+            Objects.requireNonNull(left);
+            Objects.requireNonNull(right);
+            int res = 0;
+            if (left.ident != null && right.ident != null) {
+                res = NetworkIdentitySet.compare(left.ident, right.ident);
+            }
+            if (res == 0) {
+                res = Integer.compare(left.uid, right.uid);
+            }
+            if (res == 0) {
+                res = Integer.compare(left.set, right.set);
+            }
+            if (res == 0) {
+                res = Integer.compare(left.tag, right.tag);
+            }
+            return res;
+        }
+    }
+}
diff --git a/framework-t/src/android/net/NetworkStatsHistory.aidl b/framework-t/src/android/net/NetworkStatsHistory.aidl
new file mode 100644
index 0000000..8b9069f
--- /dev/null
+++ b/framework-t/src/android/net/NetworkStatsHistory.aidl
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2011, 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;
+
+parcelable NetworkStatsHistory;
diff --git a/framework-t/src/android/net/NetworkStatsHistory.java b/framework-t/src/android/net/NetworkStatsHistory.java
new file mode 100644
index 0000000..301fef9
--- /dev/null
+++ b/framework-t/src/android/net/NetworkStatsHistory.java
@@ -0,0 +1,1162 @@
+/*
+ * Copyright (C) 2011 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.net.NetworkStats.IFACE_ALL;
+import static android.net.NetworkStats.SET_DEFAULT;
+import static android.net.NetworkStats.TAG_NONE;
+import static android.net.NetworkStats.UID_ALL;
+import static android.net.NetworkStatsHistory.DataStreamUtils.readFullLongArray;
+import static android.net.NetworkStatsHistory.DataStreamUtils.readVarLongArray;
+import static android.net.NetworkStatsHistory.DataStreamUtils.writeVarLongArray;
+import static android.net.NetworkStatsHistory.Entry.UNKNOWN;
+import static android.net.NetworkStatsHistory.ParcelUtils.readLongArray;
+import static android.net.NetworkStatsHistory.ParcelUtils.writeLongArray;
+import static android.text.format.DateUtils.SECOND_IN_MILLIS;
+
+import static com.android.net.module.util.NetworkStatsUtils.multiplySafeByRational;
+
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.compat.annotation.UnsupportedAppUsage;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.service.NetworkStatsHistoryBucketProto;
+import android.service.NetworkStatsHistoryProto;
+import android.util.IndentingPrintWriter;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.NetworkStatsUtils;
+
+import libcore.util.EmptyArray;
+
+import java.io.CharArrayWriter;
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.ProtocolException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * Collection of historical network statistics, recorded into equally-sized
+ * "buckets" in time. Internally it stores data in {@code long} series for more
+ * efficient persistence.
+ * <p>
+ * Each bucket is defined by a {@link #bucketStart} timestamp, and lasts for
+ * {@link #bucketDuration}. Internally assumes that {@link #bucketStart} is
+ * sorted at all times.
+ *
+ * @hide
+ */
+@SystemApi(client = MODULE_LIBRARIES)
+public final class NetworkStatsHistory implements Parcelable {
+    private static final int VERSION_INIT = 1;
+    private static final int VERSION_ADD_PACKETS = 2;
+    private static final int VERSION_ADD_ACTIVE = 3;
+
+    /** @hide */
+    public static final int FIELD_ACTIVE_TIME = 0x01;
+    /** @hide */
+    public static final int FIELD_RX_BYTES = 0x02;
+    /** @hide */
+    public static final int FIELD_RX_PACKETS = 0x04;
+    /** @hide */
+    public static final int FIELD_TX_BYTES = 0x08;
+    /** @hide */
+    public static final int FIELD_TX_PACKETS = 0x10;
+    /** @hide */
+    public static final int FIELD_OPERATIONS = 0x20;
+    /** @hide */
+    public static final int FIELD_ALL = 0xFFFFFFFF;
+
+    private long bucketDuration;
+    private int bucketCount;
+    private long[] bucketStart;
+    private long[] activeTime;
+    private long[] rxBytes;
+    private long[] rxPackets;
+    private long[] txBytes;
+    private long[] txPackets;
+    private long[] operations;
+    private long totalBytes;
+
+    /** @hide */
+    public NetworkStatsHistory(long bucketDuration, long[] bucketStart, long[] activeTime,
+            long[] rxBytes, long[] rxPackets, long[] txBytes, long[] txPackets,
+            long[] operations, int bucketCount, long totalBytes) {
+        this.bucketDuration = bucketDuration;
+        this.bucketStart = bucketStart;
+        this.activeTime = activeTime;
+        this.rxBytes = rxBytes;
+        this.rxPackets = rxPackets;
+        this.txBytes = txBytes;
+        this.txPackets = txPackets;
+        this.operations = operations;
+        this.bucketCount = bucketCount;
+        this.totalBytes = totalBytes;
+    }
+
+    /**
+     * An instance to represent a single record in a {@link NetworkStatsHistory} object.
+     */
+    public static final class Entry {
+        /** @hide */
+        public static final long UNKNOWN = -1;
+
+        /** @hide */
+        // TODO: Migrate all callers to get duration from the history object and remove this field.
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public long bucketDuration;
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public long bucketStart;
+        /** @hide */
+        public long activeTime;
+        /** @hide */
+        @UnsupportedAppUsage
+        public long rxBytes;
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public long rxPackets;
+        /** @hide */
+        @UnsupportedAppUsage
+        public long txBytes;
+        /** @hide */
+        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+        public long txPackets;
+        /** @hide */
+        public long operations;
+        /** @hide */
+        Entry() {}
+
+        /**
+         * Construct a {@link Entry} instance to represent a single record in a
+         * {@link NetworkStatsHistory} object.
+         *
+         * @param bucketStart Start of period for this {@link Entry}, in milliseconds since the
+         *                    Unix epoch, see {@link java.lang.System#currentTimeMillis}.
+         * @param activeTime Active time for this {@link Entry}, in milliseconds.
+         * @param rxBytes Number of bytes received for this {@link Entry}. Statistics should
+         *                represent the contents of IP packets, including IP headers.
+         * @param rxPackets Number of packets received for this {@link Entry}. Statistics should
+         *                  represent the contents of IP packets, including IP headers.
+         * @param txBytes Number of bytes transmitted for this {@link Entry}. Statistics should
+         *                represent the contents of IP packets, including IP headers.
+         * @param txPackets Number of bytes transmitted for this {@link Entry}. Statistics should
+         *                  represent the contents of IP packets, including IP headers.
+         * @param operations count of network operations performed for this {@link Entry}. This can
+         *                   be used to derive bytes-per-operation.
+         */
+        public Entry(long bucketStart, long activeTime, long rxBytes,
+                long rxPackets, long txBytes, long txPackets, long operations) {
+            this.bucketStart = bucketStart;
+            this.activeTime = activeTime;
+            this.rxBytes = rxBytes;
+            this.rxPackets = rxPackets;
+            this.txBytes = txBytes;
+            this.txPackets = txPackets;
+            this.operations = operations;
+        }
+
+        /**
+         * Get start timestamp of the bucket's time interval, in milliseconds since the Unix epoch.
+         */
+        public long getBucketStart() {
+            return bucketStart;
+        }
+
+        /**
+         * Get active time of the bucket's time interval, in milliseconds.
+         */
+        public long getActiveTime() {
+            return activeTime;
+        }
+
+        /** Get number of bytes received for this {@link Entry}. */
+        public long getRxBytes() {
+            return rxBytes;
+        }
+
+        /** Get number of packets received for this {@link Entry}. */
+        public long getRxPackets() {
+            return rxPackets;
+        }
+
+        /** Get number of bytes transmitted for this {@link Entry}. */
+        public long getTxBytes() {
+            return txBytes;
+        }
+
+        /** Get number of packets transmitted for this {@link Entry}. */
+        public long getTxPackets() {
+            return txPackets;
+        }
+
+        /** Get count of network operations performed for this {@link Entry}. */
+        public long getOperations() {
+            return operations;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o.getClass() != getClass()) return false;
+            Entry entry = (Entry) o;
+            return bucketStart == entry.bucketStart
+                    && activeTime == entry.activeTime && rxBytes == entry.rxBytes
+                    && rxPackets == entry.rxPackets && txBytes == entry.txBytes
+                    && txPackets == entry.txPackets && operations == entry.operations;
+        }
+
+        @Override
+        public int hashCode() {
+            return (int) (bucketStart * 2
+                    + activeTime * 3
+                    + rxBytes * 5
+                    + rxPackets * 7
+                    + txBytes * 11
+                    + txPackets * 13
+                    + operations * 17);
+        }
+
+        @Override
+        public String toString() {
+            return "Entry{"
+                    + "bucketStart=" + bucketStart
+                    + ", activeTime=" + activeTime
+                    + ", rxBytes=" + rxBytes
+                    + ", rxPackets=" + rxPackets
+                    + ", txBytes=" + txBytes
+                    + ", txPackets=" + txPackets
+                    + ", operations=" + operations
+                    + "}";
+        }
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage
+    public NetworkStatsHistory(long bucketDuration) {
+        this(bucketDuration, 10, FIELD_ALL);
+    }
+
+    /** @hide */
+    public NetworkStatsHistory(long bucketDuration, int initialSize) {
+        this(bucketDuration, initialSize, FIELD_ALL);
+    }
+
+    /** @hide */
+    public NetworkStatsHistory(long bucketDuration, int initialSize, int fields) {
+        this.bucketDuration = bucketDuration;
+        bucketStart = new long[initialSize];
+        if ((fields & FIELD_ACTIVE_TIME) != 0) activeTime = new long[initialSize];
+        if ((fields & FIELD_RX_BYTES) != 0) rxBytes = new long[initialSize];
+        if ((fields & FIELD_RX_PACKETS) != 0) rxPackets = new long[initialSize];
+        if ((fields & FIELD_TX_BYTES) != 0) txBytes = new long[initialSize];
+        if ((fields & FIELD_TX_PACKETS) != 0) txPackets = new long[initialSize];
+        if ((fields & FIELD_OPERATIONS) != 0) operations = new long[initialSize];
+        bucketCount = 0;
+        totalBytes = 0;
+    }
+
+    /** @hide */
+    public NetworkStatsHistory(NetworkStatsHistory existing, long bucketDuration) {
+        this(bucketDuration, existing.estimateResizeBuckets(bucketDuration));
+        recordEntireHistory(existing);
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public NetworkStatsHistory(Parcel in) {
+        bucketDuration = in.readLong();
+        bucketStart = readLongArray(in);
+        activeTime = readLongArray(in);
+        rxBytes = readLongArray(in);
+        rxPackets = readLongArray(in);
+        txBytes = readLongArray(in);
+        txPackets = readLongArray(in);
+        operations = readLongArray(in);
+        bucketCount = bucketStart.length;
+        totalBytes = in.readLong();
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel out, int flags) {
+        out.writeLong(bucketDuration);
+        writeLongArray(out, bucketStart, bucketCount);
+        writeLongArray(out, activeTime, bucketCount);
+        writeLongArray(out, rxBytes, bucketCount);
+        writeLongArray(out, rxPackets, bucketCount);
+        writeLongArray(out, txBytes, bucketCount);
+        writeLongArray(out, txPackets, bucketCount);
+        writeLongArray(out, operations, bucketCount);
+        out.writeLong(totalBytes);
+    }
+
+    /** @hide */
+    public NetworkStatsHistory(DataInput in) throws IOException {
+        final int version = in.readInt();
+        switch (version) {
+            case VERSION_INIT: {
+                bucketDuration = in.readLong();
+                bucketStart = readFullLongArray(in);
+                rxBytes = readFullLongArray(in);
+                rxPackets = new long[bucketStart.length];
+                txBytes = readFullLongArray(in);
+                txPackets = new long[bucketStart.length];
+                operations = new long[bucketStart.length];
+                bucketCount = bucketStart.length;
+                totalBytes = CollectionUtils.total(rxBytes) + CollectionUtils.total(txBytes);
+                break;
+            }
+            case VERSION_ADD_PACKETS:
+            case VERSION_ADD_ACTIVE: {
+                bucketDuration = in.readLong();
+                bucketStart = readVarLongArray(in);
+                activeTime = (version >= VERSION_ADD_ACTIVE) ? readVarLongArray(in)
+                        : new long[bucketStart.length];
+                rxBytes = readVarLongArray(in);
+                rxPackets = readVarLongArray(in);
+                txBytes = readVarLongArray(in);
+                txPackets = readVarLongArray(in);
+                operations = readVarLongArray(in);
+                bucketCount = bucketStart.length;
+                totalBytes = CollectionUtils.total(rxBytes) + CollectionUtils.total(txBytes);
+                break;
+            }
+            default: {
+                throw new ProtocolException("unexpected version: " + version);
+            }
+        }
+
+        if (bucketStart.length != bucketCount || rxBytes.length != bucketCount
+                || rxPackets.length != bucketCount || txBytes.length != bucketCount
+                || txPackets.length != bucketCount || operations.length != bucketCount) {
+            throw new ProtocolException("Mismatched history lengths");
+        }
+    }
+
+    /** @hide */
+    public void writeToStream(DataOutput out) throws IOException {
+        out.writeInt(VERSION_ADD_ACTIVE);
+        out.writeLong(bucketDuration);
+        writeVarLongArray(out, bucketStart, bucketCount);
+        writeVarLongArray(out, activeTime, bucketCount);
+        writeVarLongArray(out, rxBytes, bucketCount);
+        writeVarLongArray(out, rxPackets, bucketCount);
+        writeVarLongArray(out, txBytes, bucketCount);
+        writeVarLongArray(out, txPackets, bucketCount);
+        writeVarLongArray(out, operations, bucketCount);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public int size() {
+        return bucketCount;
+    }
+
+    /** @hide */
+    public long getBucketDuration() {
+        return bucketDuration;
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage
+    public long getStart() {
+        if (bucketCount > 0) {
+            return bucketStart[0];
+        } else {
+            return Long.MAX_VALUE;
+        }
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage
+    public long getEnd() {
+        if (bucketCount > 0) {
+            return bucketStart[bucketCount - 1] + bucketDuration;
+        } else {
+            return Long.MIN_VALUE;
+        }
+    }
+
+    /**
+     * Return total bytes represented by this history.
+     * @hide
+     */
+    public long getTotalBytes() {
+        return totalBytes;
+    }
+
+    /**
+     * Return index of bucket that contains or is immediately before the
+     * requested time.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public int getIndexBefore(long time) {
+        int index = Arrays.binarySearch(bucketStart, 0, bucketCount, time);
+        if (index < 0) {
+            index = (~index) - 1;
+        } else {
+            index -= 1;
+        }
+        return NetworkStatsUtils.constrain(index, 0, bucketCount - 1);
+    }
+
+    /**
+     * Return index of bucket that contains or is immediately after the
+     * requested time.
+     * @hide
+     */
+    public int getIndexAfter(long time) {
+        int index = Arrays.binarySearch(bucketStart, 0, bucketCount, time);
+        if (index < 0) {
+            index = ~index;
+        } else {
+            index += 1;
+        }
+        return NetworkStatsUtils.constrain(index, 0, bucketCount - 1);
+    }
+
+    /**
+     * Return specific stats entry.
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public Entry getValues(int i, Entry recycle) {
+        final Entry entry = recycle != null ? recycle : new Entry();
+        entry.bucketStart = bucketStart[i];
+        entry.bucketDuration = bucketDuration;
+        entry.activeTime = getLong(activeTime, i, UNKNOWN);
+        entry.rxBytes = getLong(rxBytes, i, UNKNOWN);
+        entry.rxPackets = getLong(rxPackets, i, UNKNOWN);
+        entry.txBytes = getLong(txBytes, i, UNKNOWN);
+        entry.txPackets = getLong(txPackets, i, UNKNOWN);
+        entry.operations = getLong(operations, i, UNKNOWN);
+        return entry;
+    }
+
+    /**
+     * Get List of {@link Entry} of the {@link NetworkStatsHistory} instance.
+     *
+     * @return
+     */
+    @NonNull
+    public List<Entry> getEntries() {
+        // TODO: Return a wrapper that uses this list instead, to prevent the returned result
+        //  from being changed.
+        final ArrayList<Entry> ret = new ArrayList<>(size());
+        for (int i = 0; i < size(); i++) {
+            ret.add(getValues(i, null /* recycle */));
+        }
+        return ret;
+    }
+
+    /** @hide */
+    public void setValues(int i, Entry entry) {
+        // Unwind old values
+        if (rxBytes != null) totalBytes -= rxBytes[i];
+        if (txBytes != null) totalBytes -= txBytes[i];
+
+        bucketStart[i] = entry.bucketStart;
+        setLong(activeTime, i, entry.activeTime);
+        setLong(rxBytes, i, entry.rxBytes);
+        setLong(rxPackets, i, entry.rxPackets);
+        setLong(txBytes, i, entry.txBytes);
+        setLong(txPackets, i, entry.txPackets);
+        setLong(operations, i, entry.operations);
+
+        // Apply new values
+        if (rxBytes != null) totalBytes += rxBytes[i];
+        if (txBytes != null) totalBytes += txBytes[i];
+    }
+
+    /**
+     * Record that data traffic occurred in the given time range. Will
+     * distribute across internal buckets, creating new buckets as needed.
+     * @hide
+     */
+    @Deprecated
+    public void recordData(long start, long end, long rxBytes, long txBytes) {
+        recordData(start, end, new NetworkStats.Entry(
+                IFACE_ALL, UID_ALL, SET_DEFAULT, TAG_NONE, rxBytes, 0L, txBytes, 0L, 0L));
+    }
+
+    /**
+     * Record that data traffic occurred in the given time range. Will
+     * distribute across internal buckets, creating new buckets as needed.
+     * @hide
+     */
+    public void recordData(long start, long end, NetworkStats.Entry entry) {
+        long rxBytes = entry.rxBytes;
+        long rxPackets = entry.rxPackets;
+        long txBytes = entry.txBytes;
+        long txPackets = entry.txPackets;
+        long operations = entry.operations;
+
+        if (entry.isNegative()) {
+            throw new IllegalArgumentException("tried recording negative data");
+        }
+        if (entry.isEmpty()) {
+            return;
+        }
+
+        // create any buckets needed by this range
+        ensureBuckets(start, end);
+        // Return fast if there is still no entry. This would typically happen when the start,
+        // end or duration are not valid values, e.g. start > end, negative duration value, etc.
+        if (bucketCount == 0) return;
+
+        // distribute data usage into buckets
+        long duration = end - start;
+        final int startIndex = getIndexAfter(end);
+        for (int i = startIndex; i >= 0; i--) {
+            final long curStart = bucketStart[i];
+            final long curEnd = curStart + bucketDuration;
+
+            // bucket is older than record; we're finished
+            if (curEnd < start) break;
+            // bucket is newer than record; keep looking
+            if (curStart > end) continue;
+
+            final long overlap = Math.min(curEnd, end) - Math.max(curStart, start);
+            if (overlap <= 0) continue;
+
+            // integer math each time is faster than floating point
+            final long fracRxBytes = multiplySafeByRational(rxBytes, overlap, duration);
+            final long fracRxPackets = multiplySafeByRational(rxPackets, overlap, duration);
+            final long fracTxBytes = multiplySafeByRational(txBytes, overlap, duration);
+            final long fracTxPackets = multiplySafeByRational(txPackets, overlap, duration);
+            final long fracOperations = multiplySafeByRational(operations, overlap, duration);
+
+
+            addLong(activeTime, i, overlap);
+            addLong(this.rxBytes, i, fracRxBytes); rxBytes -= fracRxBytes;
+            addLong(this.rxPackets, i, fracRxPackets); rxPackets -= fracRxPackets;
+            addLong(this.txBytes, i, fracTxBytes); txBytes -= fracTxBytes;
+            addLong(this.txPackets, i, fracTxPackets); txPackets -= fracTxPackets;
+            addLong(this.operations, i, fracOperations); operations -= fracOperations;
+
+            duration -= overlap;
+        }
+
+        totalBytes += entry.rxBytes + entry.txBytes;
+    }
+
+    /**
+     * Record an entire {@link NetworkStatsHistory} into this history. Usually
+     * for combining together stats for external reporting.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public void recordEntireHistory(NetworkStatsHistory input) {
+        recordHistory(input, Long.MIN_VALUE, Long.MAX_VALUE);
+    }
+
+    /**
+     * Record given {@link NetworkStatsHistory} into this history, copying only
+     * buckets that atomically occur in the inclusive time range. Doesn't
+     * interpolate across partial buckets.
+     * @hide
+     */
+    public void recordHistory(NetworkStatsHistory input, long start, long end) {
+        final NetworkStats.Entry entry = new NetworkStats.Entry(
+                IFACE_ALL, UID_ALL, SET_DEFAULT, TAG_NONE, 0L, 0L, 0L, 0L, 0L);
+        for (int i = 0; i < input.bucketCount; i++) {
+            final long bucketStart = input.bucketStart[i];
+            final long bucketEnd = bucketStart + input.bucketDuration;
+
+            // skip when bucket is outside requested range
+            if (bucketStart < start || bucketEnd > end) continue;
+
+            entry.rxBytes = getLong(input.rxBytes, i, 0L);
+            entry.rxPackets = getLong(input.rxPackets, i, 0L);
+            entry.txBytes = getLong(input.txBytes, i, 0L);
+            entry.txPackets = getLong(input.txPackets, i, 0L);
+            entry.operations = getLong(input.operations, i, 0L);
+
+            recordData(bucketStart, bucketEnd, entry);
+        }
+    }
+
+    /**
+     * Ensure that buckets exist for given time range, creating as needed.
+     */
+    private void ensureBuckets(long start, long end) {
+        // normalize incoming range to bucket boundaries
+        start -= start % bucketDuration;
+        end += (bucketDuration - (end % bucketDuration)) % bucketDuration;
+
+        for (long now = start; now < end; now += bucketDuration) {
+            // try finding existing bucket
+            final int index = Arrays.binarySearch(bucketStart, 0, bucketCount, now);
+            if (index < 0) {
+                // bucket missing, create and insert
+                insertBucket(~index, now);
+            }
+        }
+    }
+
+    /**
+     * Insert new bucket at requested index and starting time.
+     */
+    private void insertBucket(int index, long start) {
+        // create more buckets when needed
+        if (bucketCount >= bucketStart.length) {
+            final int newLength = Math.max(bucketStart.length, 10) * 3 / 2;
+            bucketStart = Arrays.copyOf(bucketStart, newLength);
+            if (activeTime != null) activeTime = Arrays.copyOf(activeTime, newLength);
+            if (rxBytes != null) rxBytes = Arrays.copyOf(rxBytes, newLength);
+            if (rxPackets != null) rxPackets = Arrays.copyOf(rxPackets, newLength);
+            if (txBytes != null) txBytes = Arrays.copyOf(txBytes, newLength);
+            if (txPackets != null) txPackets = Arrays.copyOf(txPackets, newLength);
+            if (operations != null) operations = Arrays.copyOf(operations, newLength);
+        }
+
+        // create gap when inserting bucket in middle
+        if (index < bucketCount) {
+            final int dstPos = index + 1;
+            final int length = bucketCount - index;
+
+            System.arraycopy(bucketStart, index, bucketStart, dstPos, length);
+            if (activeTime != null) System.arraycopy(activeTime, index, activeTime, dstPos, length);
+            if (rxBytes != null) System.arraycopy(rxBytes, index, rxBytes, dstPos, length);
+            if (rxPackets != null) System.arraycopy(rxPackets, index, rxPackets, dstPos, length);
+            if (txBytes != null) System.arraycopy(txBytes, index, txBytes, dstPos, length);
+            if (txPackets != null) System.arraycopy(txPackets, index, txPackets, dstPos, length);
+            if (operations != null) System.arraycopy(operations, index, operations, dstPos, length);
+        }
+
+        bucketStart[index] = start;
+        setLong(activeTime, index, 0L);
+        setLong(rxBytes, index, 0L);
+        setLong(rxPackets, index, 0L);
+        setLong(txBytes, index, 0L);
+        setLong(txPackets, index, 0L);
+        setLong(operations, index, 0L);
+        bucketCount++;
+    }
+
+    /**
+     * Clear all data stored in this object.
+     * @hide
+     */
+    public void clear() {
+        bucketStart = EmptyArray.LONG;
+        if (activeTime != null) activeTime = EmptyArray.LONG;
+        if (rxBytes != null) rxBytes = EmptyArray.LONG;
+        if (rxPackets != null) rxPackets = EmptyArray.LONG;
+        if (txBytes != null) txBytes = EmptyArray.LONG;
+        if (txPackets != null) txPackets = EmptyArray.LONG;
+        if (operations != null) operations = EmptyArray.LONG;
+        bucketCount = 0;
+        totalBytes = 0;
+    }
+
+    /**
+     * Remove buckets older than requested cutoff.
+     * @hide
+     */
+    public void removeBucketsBefore(long cutoff) {
+        // TODO: Consider use getIndexBefore.
+        int i;
+        for (i = 0; i < bucketCount; i++) {
+            final long curStart = bucketStart[i];
+            final long curEnd = curStart + bucketDuration;
+
+            // cutoff happens before or during this bucket; everything before
+            // this bucket should be removed.
+            if (curEnd > cutoff) break;
+        }
+
+        if (i > 0) {
+            final int length = bucketStart.length;
+            bucketStart = Arrays.copyOfRange(bucketStart, i, length);
+            if (activeTime != null) activeTime = Arrays.copyOfRange(activeTime, i, length);
+            if (rxBytes != null) rxBytes = Arrays.copyOfRange(rxBytes, i, length);
+            if (rxPackets != null) rxPackets = Arrays.copyOfRange(rxPackets, i, length);
+            if (txBytes != null) txBytes = Arrays.copyOfRange(txBytes, i, length);
+            if (txPackets != null) txPackets = Arrays.copyOfRange(txPackets, i, length);
+            if (operations != null) operations = Arrays.copyOfRange(operations, i, length);
+            bucketCount -= i;
+
+            totalBytes = 0;
+            if (rxBytes != null) totalBytes += CollectionUtils.total(rxBytes);
+            if (txBytes != null) totalBytes += CollectionUtils.total(txBytes);
+        }
+    }
+
+    /**
+     * Return interpolated data usage across the requested range. Interpolates
+     * across buckets, so values may be rounded slightly.
+     *
+     * <p>If the active bucket is not completed yet, it returns the proportional value of it
+     * based on its duration and the {@code end} param.
+     *
+     * @param start - start of the range, timestamp in milliseconds since the epoch.
+     * @param end - end of the range, timestamp in milliseconds since the epoch.
+     * @param recycle - entry instance for performance, could be null.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public Entry getValues(long start, long end, Entry recycle) {
+        return getValues(start, end, Long.MAX_VALUE, recycle);
+    }
+
+    /**
+     * Return interpolated data usage across the requested range. Interpolates
+     * across buckets, so values may be rounded slightly.
+     *
+     * @param start - start of the range, timestamp in milliseconds since the epoch.
+     * @param end - end of the range, timestamp in milliseconds since the epoch.
+     * @param now - current timestamp in milliseconds since the epoch (wall clock).
+     * @param recycle - entry instance for performance, could be null.
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public Entry getValues(long start, long end, long now, Entry recycle) {
+        final Entry entry = recycle != null ? recycle : new Entry();
+        entry.bucketDuration = end - start;
+        entry.bucketStart = start;
+        entry.activeTime = activeTime != null ? 0 : UNKNOWN;
+        entry.rxBytes = rxBytes != null ? 0 : UNKNOWN;
+        entry.rxPackets = rxPackets != null ? 0 : UNKNOWN;
+        entry.txBytes = txBytes != null ? 0 : UNKNOWN;
+        entry.txPackets = txPackets != null ? 0 : UNKNOWN;
+        entry.operations = operations != null ? 0 : UNKNOWN;
+
+        // Return fast if there is no entry.
+        if (bucketCount == 0) return entry;
+
+        final int startIndex = getIndexAfter(end);
+        for (int i = startIndex; i >= 0; i--) {
+            final long curStart = bucketStart[i];
+            long curEnd = curStart + bucketDuration;
+
+            // bucket is older than request; we're finished
+            if (curEnd <= start) break;
+            // bucket is newer than request; keep looking
+            if (curStart >= end) continue;
+
+            // the active bucket is shorter then a normal completed bucket
+            if (curEnd > now) curEnd = now;
+            // usually this is simply bucketDuration
+            final long bucketSpan = curEnd - curStart;
+            // prevent division by zero
+            if (bucketSpan <= 0) continue;
+
+            final long overlapEnd = curEnd < end ? curEnd : end;
+            final long overlapStart = curStart > start ? curStart : start;
+            final long overlap = overlapEnd - overlapStart;
+            if (overlap <= 0) continue;
+
+            // integer math each time is faster than floating point
+            if (activeTime != null) {
+                entry.activeTime += multiplySafeByRational(activeTime[i], overlap, bucketSpan);
+            }
+            if (rxBytes != null) {
+                entry.rxBytes += multiplySafeByRational(rxBytes[i], overlap, bucketSpan);
+            }
+            if (rxPackets != null) {
+                entry.rxPackets += multiplySafeByRational(rxPackets[i], overlap, bucketSpan);
+            }
+            if (txBytes != null) {
+                entry.txBytes += multiplySafeByRational(txBytes[i], overlap, bucketSpan);
+            }
+            if (txPackets != null) {
+                entry.txPackets += multiplySafeByRational(txPackets[i], overlap, bucketSpan);
+            }
+            if (operations != null) {
+                entry.operations += multiplySafeByRational(operations[i], overlap, bucketSpan);
+            }
+        }
+        return entry;
+    }
+
+    /**
+     * @deprecated only for temporary testing
+     * @hide
+     */
+    @Deprecated
+    public void generateRandom(long start, long end, long bytes) {
+        final Random r = new Random();
+
+        final float fractionRx = r.nextFloat();
+        final long rxBytes = (long) (bytes * fractionRx);
+        final long txBytes = (long) (bytes * (1 - fractionRx));
+
+        final long rxPackets = rxBytes / 1024;
+        final long txPackets = txBytes / 1024;
+        final long operations = rxBytes / 2048;
+
+        generateRandom(start, end, rxBytes, rxPackets, txBytes, txPackets, operations, r);
+    }
+
+    /**
+     * @deprecated only for temporary testing
+     * @hide
+     */
+    @Deprecated
+    public void generateRandom(long start, long end, long rxBytes, long rxPackets, long txBytes,
+            long txPackets, long operations, Random r) {
+        ensureBuckets(start, end);
+
+        final NetworkStats.Entry entry = new NetworkStats.Entry(
+                IFACE_ALL, UID_ALL, SET_DEFAULT, TAG_NONE, 0L, 0L, 0L, 0L, 0L);
+        while (rxBytes > 1024 || rxPackets > 128 || txBytes > 1024 || txPackets > 128
+                || operations > 32) {
+            final long curStart = randomLong(r, start, end);
+            final long curEnd = curStart + randomLong(r, 0, (end - curStart) / 2);
+
+            entry.rxBytes = randomLong(r, 0, rxBytes);
+            entry.rxPackets = randomLong(r, 0, rxPackets);
+            entry.txBytes = randomLong(r, 0, txBytes);
+            entry.txPackets = randomLong(r, 0, txPackets);
+            entry.operations = randomLong(r, 0, operations);
+
+            rxBytes -= entry.rxBytes;
+            rxPackets -= entry.rxPackets;
+            txBytes -= entry.txBytes;
+            txPackets -= entry.txPackets;
+            operations -= entry.operations;
+
+            recordData(curStart, curEnd, entry);
+        }
+    }
+
+    /** @hide */
+    public static long randomLong(Random r, long start, long end) {
+        return (long) (start + (r.nextFloat() * (end - start)));
+    }
+
+    /**
+     * Quickly determine if this history intersects with given window.
+     * @hide
+     */
+    public boolean intersects(long start, long end) {
+        final long dataStart = getStart();
+        final long dataEnd = getEnd();
+        if (start >= dataStart && start <= dataEnd) return true;
+        if (end >= dataStart && end <= dataEnd) return true;
+        if (dataStart >= start && dataStart <= end) return true;
+        if (dataEnd >= start && dataEnd <= end) return true;
+        return false;
+    }
+
+    /** @hide */
+    public void dump(IndentingPrintWriter pw, boolean fullHistory) {
+        pw.print("NetworkStatsHistory: bucketDuration=");
+        pw.println(bucketDuration / SECOND_IN_MILLIS);
+        pw.increaseIndent();
+
+        final int start = fullHistory ? 0 : Math.max(0, bucketCount - 32);
+        if (start > 0) {
+            pw.print("(omitting "); pw.print(start); pw.println(" buckets)");
+        }
+
+        for (int i = start; i < bucketCount; i++) {
+            pw.print("st="); pw.print(bucketStart[i] / SECOND_IN_MILLIS);
+            if (rxBytes != null) { pw.print(" rb="); pw.print(rxBytes[i]); }
+            if (rxPackets != null) { pw.print(" rp="); pw.print(rxPackets[i]); }
+            if (txBytes != null) { pw.print(" tb="); pw.print(txBytes[i]); }
+            if (txPackets != null) { pw.print(" tp="); pw.print(txPackets[i]); }
+            if (operations != null) { pw.print(" op="); pw.print(operations[i]); }
+            pw.println();
+        }
+
+        pw.decreaseIndent();
+    }
+
+    /** @hide */
+    public void dumpCheckin(PrintWriter pw) {
+        pw.print("d,");
+        pw.print(bucketDuration / SECOND_IN_MILLIS);
+        pw.println();
+
+        for (int i = 0; i < bucketCount; i++) {
+            pw.print("b,");
+            pw.print(bucketStart[i] / SECOND_IN_MILLIS); pw.print(',');
+            if (rxBytes != null) { pw.print(rxBytes[i]); } else { pw.print("*"); } pw.print(',');
+            if (rxPackets != null) { pw.print(rxPackets[i]); } else { pw.print("*"); } pw.print(',');
+            if (txBytes != null) { pw.print(txBytes[i]); } else { pw.print("*"); } pw.print(',');
+            if (txPackets != null) { pw.print(txPackets[i]); } else { pw.print("*"); } pw.print(',');
+            if (operations != null) { pw.print(operations[i]); } else { pw.print("*"); }
+            pw.println();
+        }
+    }
+
+    /** @hide */
+    public void dumpDebug(ProtoOutputStream proto, long tag) {
+        final long start = proto.start(tag);
+
+        proto.write(NetworkStatsHistoryProto.BUCKET_DURATION_MS, bucketDuration);
+
+        for (int i = 0; i < bucketCount; i++) {
+            final long startBucket = proto.start(NetworkStatsHistoryProto.BUCKETS);
+
+            proto.write(NetworkStatsHistoryBucketProto.BUCKET_START_MS,
+                    bucketStart[i]);
+            dumpDebug(proto, NetworkStatsHistoryBucketProto.RX_BYTES, rxBytes, i);
+            dumpDebug(proto, NetworkStatsHistoryBucketProto.RX_PACKETS, rxPackets, i);
+            dumpDebug(proto, NetworkStatsHistoryBucketProto.TX_BYTES, txBytes, i);
+            dumpDebug(proto, NetworkStatsHistoryBucketProto.TX_PACKETS, txPackets, i);
+            dumpDebug(proto, NetworkStatsHistoryBucketProto.OPERATIONS, operations, i);
+
+            proto.end(startBucket);
+        }
+
+        proto.end(start);
+    }
+
+    private static void dumpDebug(ProtoOutputStream proto, long tag, long[] array, int index) {
+        if (array != null) {
+            proto.write(tag, array[index]);
+        }
+    }
+
+    @Override
+    public String toString() {
+        final CharArrayWriter writer = new CharArrayWriter();
+        dump(new IndentingPrintWriter(writer, "  "), false);
+        return writer.toString();
+    }
+
+    @UnsupportedAppUsage
+    public static final @android.annotation.NonNull Creator<NetworkStatsHistory> CREATOR = new Creator<NetworkStatsHistory>() {
+        @Override
+        public NetworkStatsHistory createFromParcel(Parcel in) {
+            return new NetworkStatsHistory(in);
+        }
+
+        @Override
+        public NetworkStatsHistory[] newArray(int size) {
+            return new NetworkStatsHistory[size];
+        }
+    };
+
+    private static long getLong(long[] array, int i, long value) {
+        return array != null ? array[i] : value;
+    }
+
+    private static void setLong(long[] array, int i, long value) {
+        if (array != null) array[i] = value;
+    }
+
+    private static void addLong(long[] array, int i, long value) {
+        if (array != null) array[i] += value;
+    }
+
+    /** @hide */
+    public int estimateResizeBuckets(long newBucketDuration) {
+        return (int) (size() * getBucketDuration() / newBucketDuration);
+    }
+
+    /**
+     * Utility methods for interacting with {@link DataInputStream} and
+     * {@link DataOutputStream}, mostly dealing with writing partial arrays.
+     * @hide
+     */
+    public static class DataStreamUtils {
+        @Deprecated
+        public static long[] readFullLongArray(DataInput in) throws IOException {
+            final int size = in.readInt();
+            if (size < 0) throw new ProtocolException("negative array size");
+            final long[] values = new long[size];
+            for (int i = 0; i < values.length; i++) {
+                values[i] = in.readLong();
+            }
+            return values;
+        }
+
+        /**
+         * Read variable-length {@link Long} using protobuf-style approach.
+         */
+        public static long readVarLong(DataInput in) throws IOException {
+            int shift = 0;
+            long result = 0;
+            while (shift < 64) {
+                byte b = in.readByte();
+                result |= (long) (b & 0x7F) << shift;
+                if ((b & 0x80) == 0)
+                    return result;
+                shift += 7;
+            }
+            throw new ProtocolException("malformed long");
+        }
+
+        /**
+         * Write variable-length {@link Long} using protobuf-style approach.
+         */
+        public static void writeVarLong(DataOutput out, long value) throws IOException {
+            while (true) {
+                if ((value & ~0x7FL) == 0) {
+                    out.writeByte((int) value);
+                    return;
+                } else {
+                    out.writeByte(((int) value & 0x7F) | 0x80);
+                    value >>>= 7;
+                }
+            }
+        }
+
+        public static long[] readVarLongArray(DataInput in) throws IOException {
+            final int size = in.readInt();
+            if (size == -1) return null;
+            if (size < 0) throw new ProtocolException("negative array size");
+            final long[] values = new long[size];
+            for (int i = 0; i < values.length; i++) {
+                values[i] = readVarLong(in);
+            }
+            return values;
+        }
+
+        public static void writeVarLongArray(DataOutput out, long[] values, int size)
+                throws IOException {
+            if (values == null) {
+                out.writeInt(-1);
+                return;
+            }
+            if (size > values.length) {
+                throw new IllegalArgumentException("size larger than length");
+            }
+            out.writeInt(size);
+            for (int i = 0; i < size; i++) {
+                writeVarLong(out, values[i]);
+            }
+        }
+    }
+
+    /**
+     * Utility methods for interacting with {@link Parcel} structures, mostly
+     * dealing with writing partial arrays.
+     * @hide
+     */
+    public static class ParcelUtils {
+        public static long[] readLongArray(Parcel in) {
+            final int size = in.readInt();
+            if (size == -1) return null;
+            final long[] values = new long[size];
+            for (int i = 0; i < values.length; i++) {
+                values[i] = in.readLong();
+            }
+            return values;
+        }
+
+        public static void writeLongArray(Parcel out, long[] values, int size) {
+            if (values == null) {
+                out.writeInt(-1);
+                return;
+            }
+            if (size > values.length) {
+                throw new IllegalArgumentException("size larger than length");
+            }
+            out.writeInt(size);
+            for (int i = 0; i < size; i++) {
+                out.writeLong(values[i]);
+            }
+        }
+    }
+
+    /**
+     * Builder class for {@link NetworkStatsHistory}.
+     */
+    public static final class Builder {
+        private final long mBucketDuration;
+        private final List<Long> mBucketStart;
+        private final List<Long> mActiveTime;
+        private final List<Long> mRxBytes;
+        private final List<Long> mRxPackets;
+        private final List<Long> mTxBytes;
+        private final List<Long> mTxPackets;
+        private final List<Long> mOperations;
+
+        /**
+         * Creates a new Builder with given bucket duration and initial capacity to construct
+         * {@link NetworkStatsHistory} objects.
+         *
+         * @param bucketDuration Duration of the buckets of the object, in milliseconds.
+         * @param initialCapacity Estimated number of records.
+         */
+        public Builder(long bucketDuration, int initialCapacity) {
+            mBucketDuration = bucketDuration;
+            mBucketStart = new ArrayList<>(initialCapacity);
+            mActiveTime = new ArrayList<>(initialCapacity);
+            mRxBytes = new ArrayList<>(initialCapacity);
+            mRxPackets = new ArrayList<>(initialCapacity);
+            mTxBytes = new ArrayList<>(initialCapacity);
+            mTxPackets = new ArrayList<>(initialCapacity);
+            mOperations = new ArrayList<>(initialCapacity);
+        }
+
+        /**
+         * Add an {@link Entry} into the {@link NetworkStatsHistory} instance.
+         *
+         * @param entry The target {@link Entry} object.
+         * @return The builder object.
+         */
+        @NonNull
+        public Builder addEntry(@NonNull Entry entry) {
+            mBucketStart.add(entry.bucketStart);
+            mActiveTime.add(entry.activeTime);
+            mRxBytes.add(entry.rxBytes);
+            mRxPackets.add(entry.rxPackets);
+            mTxBytes.add(entry.txBytes);
+            mTxPackets.add(entry.txPackets);
+            mOperations.add(entry.operations);
+            return this;
+        }
+
+        private static long sum(@NonNull List<Long> list) {
+            long sum = 0;
+            for (long entry : list) {
+                sum += entry;
+            }
+            return sum;
+        }
+
+        /**
+         * Builds the instance of the {@link NetworkStatsHistory}.
+         *
+         * @return the built instance of {@link NetworkStatsHistory}.
+         */
+        @NonNull
+        public NetworkStatsHistory build() {
+            return new NetworkStatsHistory(mBucketDuration,
+                    CollectionUtils.toLongArray(mBucketStart),
+                    CollectionUtils.toLongArray(mActiveTime),
+                    CollectionUtils.toLongArray(mRxBytes),
+                    CollectionUtils.toLongArray(mRxPackets),
+                    CollectionUtils.toLongArray(mTxBytes),
+                    CollectionUtils.toLongArray(mTxPackets),
+                    CollectionUtils.toLongArray(mOperations),
+                    mBucketStart.size(),
+                    sum(mRxBytes) + sum(mTxBytes));
+        }
+    }
+}
diff --git a/framework-t/src/android/net/NetworkTemplate.java b/framework-t/src/android/net/NetworkTemplate.java
new file mode 100644
index 0000000..7b5afd7
--- /dev/null
+++ b/framework-t/src/android/net/NetworkTemplate.java
@@ -0,0 +1,1119 @@
+/*
+ * Copyright (C) 2011 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.net.ConnectivityManager.TYPE_BLUETOOTH;
+import static android.net.ConnectivityManager.TYPE_ETHERNET;
+import static android.net.ConnectivityManager.TYPE_MOBILE;
+import static android.net.ConnectivityManager.TYPE_PROXY;
+import static android.net.ConnectivityManager.TYPE_WIFI;
+import static android.net.ConnectivityManager.TYPE_WIFI_P2P;
+import static android.net.ConnectivityManager.TYPE_WIMAX;
+import static android.net.NetworkIdentity.OEM_NONE;
+import static android.net.NetworkIdentity.OEM_PAID;
+import static android.net.NetworkIdentity.OEM_PRIVATE;
+import static android.net.NetworkStats.DEFAULT_NETWORK_ALL;
+import static android.net.NetworkStats.DEFAULT_NETWORK_NO;
+import static android.net.NetworkStats.DEFAULT_NETWORK_YES;
+import static android.net.NetworkStats.METERED_ALL;
+import static android.net.NetworkStats.METERED_NO;
+import static android.net.NetworkStats.METERED_YES;
+import static android.net.NetworkStats.ROAMING_ALL;
+import static android.net.NetworkStats.ROAMING_NO;
+import static android.net.NetworkStats.ROAMING_YES;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.app.usage.NetworkStatsManager;
+import android.compat.annotation.UnsupportedAppUsage;
+import android.net.wifi.WifiInfo;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.ArraySet;
+
+import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.NetworkIdentityUtils;
+import com.android.net.module.util.NetworkStatsUtils;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+/**
+ * Predicate used to match {@link NetworkIdentity}, usually when collecting
+ * statistics. (It should probably have been named {@code NetworkPredicate}.)
+ *
+ * @hide
+ */
+@SystemApi(client = MODULE_LIBRARIES)
+public final class NetworkTemplate implements Parcelable {
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "MATCH_" }, value = {
+            MATCH_MOBILE,
+            MATCH_WIFI,
+            MATCH_ETHERNET,
+            MATCH_BLUETOOTH,
+            MATCH_PROXY,
+            MATCH_CARRIER,
+    })
+    public @interface TemplateMatchRule{}
+
+    /** Match rule to match cellular networks with given Subscriber Ids. */
+    public static final int MATCH_MOBILE = 1;
+    /** Match rule to match wifi networks. */
+    public static final int MATCH_WIFI = 4;
+    /** Match rule to match ethernet networks. */
+    public static final int MATCH_ETHERNET = 5;
+    /**
+     * Match rule to match all cellular networks.
+     *
+     * @hide
+     */
+    public static final int MATCH_MOBILE_WILDCARD = 6;
+    /**
+     * Match rule to match all wifi networks.
+     *
+     * @hide
+     */
+    public static final int MATCH_WIFI_WILDCARD = 7;
+    /** Match rule to match bluetooth networks. */
+    public static final int MATCH_BLUETOOTH = 8;
+    /**
+     * Match rule to match networks with {@link ConnectivityManager#TYPE_PROXY} as the legacy
+     * network type.
+     */
+    public static final int MATCH_PROXY = 9;
+    /**
+     * Match rule to match all networks with subscriberId inside the template. Some carriers
+     * may offer non-cellular networks like WiFi, which will be matched by this rule.
+     */
+    public static final int MATCH_CARRIER = 10;
+
+    // TODO: Remove this and replace all callers with WIFI_NETWORK_KEY_ALL.
+    /** @hide */
+    public static final String WIFI_NETWORKID_ALL = null;
+
+    /**
+     * Wi-Fi Network Key is never supposed to be null (if it is, it is a bug that
+     * should be fixed), so it's not possible to want to match null vs
+     * non-null. Therefore it's fine to use null as a sentinel for Wifi Network Key.
+     *
+     * @hide
+     */
+    public static final String WIFI_NETWORK_KEY_ALL = WIFI_NETWORKID_ALL;
+
+    /**
+     * Include all network types when filtering. This is meant to merge in with the
+     * {@code TelephonyManager.NETWORK_TYPE_*} constants, and thus needs to stay in sync.
+     */
+    public static final int NETWORK_TYPE_ALL = -1;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "OEM_MANAGED_" }, value = {
+            OEM_MANAGED_ALL,
+            OEM_MANAGED_NO,
+            OEM_MANAGED_YES,
+            OEM_MANAGED_PAID,
+            OEM_MANAGED_PRIVATE
+    })
+    public @interface OemManaged{}
+
+    /**
+     * Value to match both OEM managed and unmanaged networks (all networks).
+     */
+    public static final int OEM_MANAGED_ALL = -1;
+    /**
+     * Value to match networks which are not OEM managed.
+     */
+    public static final int OEM_MANAGED_NO = OEM_NONE;
+    /**
+     * Value to match any OEM managed network.
+     */
+    public static final int OEM_MANAGED_YES = -2;
+    /**
+     * Network has {@link NetworkCapabilities#NET_CAPABILITY_OEM_PAID}.
+     */
+    public static final int OEM_MANAGED_PAID = OEM_PAID;
+    /**
+     * Network has {@link NetworkCapabilities#NET_CAPABILITY_OEM_PRIVATE}.
+     */
+    public static final int OEM_MANAGED_PRIVATE = OEM_PRIVATE;
+
+    private static boolean isKnownMatchRule(final int rule) {
+        switch (rule) {
+            case MATCH_MOBILE:
+            case MATCH_WIFI:
+            case MATCH_ETHERNET:
+            case MATCH_MOBILE_WILDCARD:
+            case MATCH_WIFI_WILDCARD:
+            case MATCH_BLUETOOTH:
+            case MATCH_PROXY:
+            case MATCH_CARRIER:
+                return true;
+
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * Template to match {@link ConnectivityManager#TYPE_MOBILE} networks with
+     * the given IMSI.
+     *
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public static NetworkTemplate buildTemplateMobileAll(String subscriberId) {
+        return new NetworkTemplate(MATCH_MOBILE, subscriberId, null);
+    }
+
+    /**
+     * Template to match cellular networks with the given IMSI, {@code ratType} and
+     * {@code metered}. Use {@link #NETWORK_TYPE_ALL} to include all network types when
+     * filtering. See {@code TelephonyManager.NETWORK_TYPE_*}.
+     *
+     * @hide
+     */
+    public static NetworkTemplate buildTemplateMobileWithRatType(@Nullable String subscriberId,
+            int ratType, int metered) {
+        if (TextUtils.isEmpty(subscriberId)) {
+            return new NetworkTemplate(MATCH_MOBILE_WILDCARD, null /* subscriberId */,
+                    null /* matchSubscriberIds */,
+                    new String[0] /* matchWifiNetworkKeys */, metered, ROAMING_ALL,
+                    DEFAULT_NETWORK_ALL, ratType, OEM_MANAGED_ALL,
+                    NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT);
+        }
+        return new NetworkTemplate(MATCH_MOBILE, subscriberId, new String[] { subscriberId },
+                new String[0] /* matchWifiNetworkKeys */,
+                metered, ROAMING_ALL, DEFAULT_NETWORK_ALL, ratType, OEM_MANAGED_ALL,
+                NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT);
+    }
+
+    /**
+     * Template to match metered {@link ConnectivityManager#TYPE_MOBILE} networks,
+     * regardless of IMSI.
+     *
+     * @hide
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public static NetworkTemplate buildTemplateMobileWildcard() {
+        return new NetworkTemplate(MATCH_MOBILE_WILDCARD, null, null);
+    }
+
+    /**
+     * Template to match all metered {@link ConnectivityManager#TYPE_WIFI} networks,
+     * regardless of key of the wifi network.
+     *
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public static NetworkTemplate buildTemplateWifiWildcard() {
+        // TODO: Consider replace this with MATCH_WIFI with NETWORK_ID_ALL
+        // and SUBSCRIBER_ID_MATCH_RULE_ALL.
+        return new NetworkTemplate(MATCH_WIFI_WILDCARD, null, null);
+    }
+
+    /** @hide */
+    @Deprecated
+    @UnsupportedAppUsage
+    public static NetworkTemplate buildTemplateWifi() {
+        return buildTemplateWifiWildcard();
+    }
+
+    /**
+     * Template to match {@link ConnectivityManager#TYPE_WIFI} networks with the
+     * given key of the wifi network.
+     *
+     * @param wifiNetworkKey key of the wifi network. see {@link WifiInfo#getNetworkKey()}
+     *                  to know details about the key.
+     * @hide
+     */
+    public static NetworkTemplate buildTemplateWifi(@NonNull String wifiNetworkKey) {
+        Objects.requireNonNull(wifiNetworkKey);
+        return new NetworkTemplate(MATCH_WIFI, null /* subscriberId */,
+                new String[] { null } /* matchSubscriberIds */,
+                new String[] { wifiNetworkKey }, METERED_ALL, ROAMING_ALL,
+                DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL, OEM_MANAGED_ALL,
+                NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_ALL);
+    }
+
+    /**
+     * Template to match all {@link ConnectivityManager#TYPE_WIFI} networks with the given
+     * key of the wifi network and IMSI.
+     *
+     * Call with {@link #WIFI_NETWORK_KEY_ALL} for {@code wifiNetworkKey} to get result regardless
+     * of key of the wifi network.
+     *
+     * @param wifiNetworkKey key of the wifi network. see {@link WifiInfo#getNetworkKey()}
+     *                  to know details about the key.
+     * @param subscriberId the IMSI associated to this wifi network.
+     *
+     * @hide
+     */
+    public static NetworkTemplate buildTemplateWifi(@Nullable String wifiNetworkKey,
+            @Nullable String subscriberId) {
+        return new NetworkTemplate(MATCH_WIFI, subscriberId, new String[] { subscriberId },
+                wifiNetworkKey != null
+                        ? new String[] { wifiNetworkKey } : new String[0],
+                METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL, OEM_MANAGED_ALL,
+                NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT);
+    }
+
+    /**
+     * Template to combine all {@link ConnectivityManager#TYPE_ETHERNET} style
+     * networks together.
+     *
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public static NetworkTemplate buildTemplateEthernet() {
+        return new NetworkTemplate(MATCH_ETHERNET, null, null);
+    }
+
+    /**
+     * Template to combine all {@link ConnectivityManager#TYPE_BLUETOOTH} style
+     * networks together.
+     *
+     * @hide
+     */
+    public static NetworkTemplate buildTemplateBluetooth() {
+        return new NetworkTemplate(MATCH_BLUETOOTH, null, null);
+    }
+
+    /**
+     * Template to combine all {@link ConnectivityManager#TYPE_PROXY} style
+     * networks together.
+     *
+     * @hide
+     */
+    public static NetworkTemplate buildTemplateProxy() {
+        return new NetworkTemplate(MATCH_PROXY, null, null);
+    }
+
+    /**
+     * Template to match all metered carrier networks with the given IMSI.
+     *
+     * @hide
+     */
+    public static NetworkTemplate buildTemplateCarrierMetered(@NonNull String subscriberId) {
+        Objects.requireNonNull(subscriberId);
+        return new NetworkTemplate(MATCH_CARRIER, subscriberId,
+                new String[] { subscriberId },
+                new String[0] /* matchWifiNetworkKeys */,
+                METERED_YES, ROAMING_ALL,
+                DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL, OEM_MANAGED_ALL,
+                NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT);
+    }
+
+    private final int mMatchRule;
+    private final String mSubscriberId;
+
+    /**
+     * Ugh, templates are designed to target a single subscriber, but we might
+     * need to match several "merged" subscribers. These are the subscribers
+     * that should be considered to match this template.
+     * <p>
+     * Since the merge set is dynamic, it should <em>not</em> be persisted or
+     * used for determining equality.
+     */
+    private final String[] mMatchSubscriberIds;
+
+    @NonNull
+    private final String[] mMatchWifiNetworkKeys;
+
+    // Matches for the NetworkStats constants METERED_*, ROAMING_* and DEFAULT_NETWORK_*.
+    private final int mMetered;
+    private final int mRoaming;
+    private final int mDefaultNetwork;
+    private final int mRatType;
+    /**
+     * The subscriber Id match rule defines how the template should match networks with
+     * specific subscriberId(s). See NetworkTemplate#SUBSCRIBER_ID_MATCH_RULE_* for more detail.
+     */
+    private final int mSubscriberIdMatchRule;
+
+    // Bitfield containing OEM network properties{@code NetworkIdentity#OEM_*}.
+    private final int mOemManaged;
+
+    private static void checkValidSubscriberIdMatchRule(int matchRule, int subscriberIdMatchRule) {
+        switch (matchRule) {
+            case MATCH_MOBILE:
+            case MATCH_CARRIER:
+                // MOBILE and CARRIER templates must always specify a subscriber ID.
+                if (subscriberIdMatchRule == NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_ALL) {
+                    throw new IllegalArgumentException("Invalid SubscriberIdMatchRule "
+                            + "on match rule: " + getMatchRuleName(matchRule));
+                }
+                return;
+            default:
+                return;
+        }
+    }
+
+    /** @hide */
+    // TODO: Deprecate this constructor, mark it @UnsupportedAppUsage(maxTargetSdk = S)
+    @UnsupportedAppUsage
+    public NetworkTemplate(int matchRule, String subscriberId, String wifiNetworkKey) {
+        this(matchRule, subscriberId, new String[] { subscriberId }, wifiNetworkKey);
+    }
+
+    /** @hide */
+    public NetworkTemplate(int matchRule, String subscriberId, String[] matchSubscriberIds,
+            String wifiNetworkKey) {
+        // Older versions used to only match MATCH_MOBILE and MATCH_MOBILE_WILDCARD templates
+        // to metered networks. It is now possible to match mobile with any meteredness, but
+        // in order to preserve backward compatibility of @UnsupportedAppUsage methods, this
+        //constructor passes METERED_YES for these types.
+        this(matchRule, subscriberId, matchSubscriberIds,
+                wifiNetworkKey != null ? new String[] { wifiNetworkKey } : new String[0],
+                (matchRule == MATCH_MOBILE || matchRule == MATCH_MOBILE_WILDCARD) ? METERED_YES
+                : METERED_ALL , ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                OEM_MANAGED_ALL, NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT);
+    }
+
+    /** @hide */
+    // TODO: Remove it after updating all of the caller.
+    public NetworkTemplate(int matchRule, String subscriberId, String[] matchSubscriberIds,
+            String wifiNetworkKey, int metered, int roaming, int defaultNetwork, int ratType,
+            int oemManaged) {
+        this(matchRule, subscriberId, matchSubscriberIds,
+                wifiNetworkKey != null ? new String[] { wifiNetworkKey } : new String[0],
+                metered, roaming, defaultNetwork, ratType, oemManaged,
+                NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT);
+    }
+
+    /** @hide */
+    public NetworkTemplate(int matchRule, String subscriberId, String[] matchSubscriberIds,
+            String[] matchWifiNetworkKeys, int metered, int roaming,
+            int defaultNetwork, int ratType, int oemManaged, int subscriberIdMatchRule) {
+        Objects.requireNonNull(matchWifiNetworkKeys);
+        mMatchRule = matchRule;
+        mSubscriberId = subscriberId;
+        // TODO: Check whether mMatchSubscriberIds = null or mMatchSubscriberIds = {null} when
+        // mSubscriberId is null
+        mMatchSubscriberIds = matchSubscriberIds;
+        mMatchWifiNetworkKeys = matchWifiNetworkKeys;
+        mMetered = metered;
+        mRoaming = roaming;
+        mDefaultNetwork = defaultNetwork;
+        mRatType = ratType;
+        mOemManaged = oemManaged;
+        mSubscriberIdMatchRule = subscriberIdMatchRule;
+        checkValidSubscriberIdMatchRule(matchRule, subscriberIdMatchRule);
+        if (!isKnownMatchRule(matchRule)) {
+            throw new IllegalArgumentException("Unknown network template rule " + matchRule
+                    + " will not match any identity.");
+        }
+    }
+
+    private NetworkTemplate(Parcel in) {
+        mMatchRule = in.readInt();
+        mSubscriberId = in.readString();
+        mMatchSubscriberIds = in.createStringArray();
+        mMatchWifiNetworkKeys = in.createStringArray();
+        mMetered = in.readInt();
+        mRoaming = in.readInt();
+        mDefaultNetwork = in.readInt();
+        mRatType = in.readInt();
+        mOemManaged = in.readInt();
+        mSubscriberIdMatchRule = in.readInt();
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mMatchRule);
+        dest.writeString(mSubscriberId);
+        dest.writeStringArray(mMatchSubscriberIds);
+        dest.writeStringArray(mMatchWifiNetworkKeys);
+        dest.writeInt(mMetered);
+        dest.writeInt(mRoaming);
+        dest.writeInt(mDefaultNetwork);
+        dest.writeInt(mRatType);
+        dest.writeInt(mOemManaged);
+        dest.writeInt(mSubscriberIdMatchRule);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public String toString() {
+        final StringBuilder builder = new StringBuilder("NetworkTemplate: ");
+        builder.append("matchRule=").append(getMatchRuleName(mMatchRule));
+        if (mSubscriberId != null) {
+            builder.append(", subscriberId=").append(
+                    NetworkIdentityUtils.scrubSubscriberId(mSubscriberId));
+        }
+        if (mMatchSubscriberIds != null) {
+            builder.append(", matchSubscriberIds=").append(
+                    Arrays.toString(NetworkIdentityUtils.scrubSubscriberIds(mMatchSubscriberIds)));
+        }
+        builder.append(", matchWifiNetworkKeys=").append(Arrays.toString(mMatchWifiNetworkKeys));
+        if (mMetered != METERED_ALL) {
+            builder.append(", metered=").append(NetworkStats.meteredToString(mMetered));
+        }
+        if (mRoaming != ROAMING_ALL) {
+            builder.append(", roaming=").append(NetworkStats.roamingToString(mRoaming));
+        }
+        if (mDefaultNetwork != DEFAULT_NETWORK_ALL) {
+            builder.append(", defaultNetwork=").append(NetworkStats.defaultNetworkToString(
+                    mDefaultNetwork));
+        }
+        if (mRatType != NETWORK_TYPE_ALL) {
+            builder.append(", ratType=").append(mRatType);
+        }
+        if (mOemManaged != OEM_MANAGED_ALL) {
+            builder.append(", oemManaged=").append(getOemManagedNames(mOemManaged));
+        }
+        builder.append(", subscriberIdMatchRule=")
+                .append(subscriberIdMatchRuleToString(mSubscriberIdMatchRule));
+        return builder.toString();
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mMatchRule, mSubscriberId, Arrays.hashCode(mMatchWifiNetworkKeys),
+                mMetered, mRoaming, mDefaultNetwork, mRatType, mOemManaged, mSubscriberIdMatchRule);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj instanceof NetworkTemplate) {
+            final NetworkTemplate other = (NetworkTemplate) obj;
+            return mMatchRule == other.mMatchRule
+                    && Objects.equals(mSubscriberId, other.mSubscriberId)
+                    && mMetered == other.mMetered
+                    && mRoaming == other.mRoaming
+                    && mDefaultNetwork == other.mDefaultNetwork
+                    && mRatType == other.mRatType
+                    && mOemManaged == other.mOemManaged
+                    && mSubscriberIdMatchRule == other.mSubscriberIdMatchRule
+                    && Arrays.equals(mMatchWifiNetworkKeys, other.mMatchWifiNetworkKeys);
+        }
+        return false;
+    }
+
+    private static String subscriberIdMatchRuleToString(int rule) {
+        switch (rule) {
+            case NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT:
+                return "EXACT_MATCH";
+            case NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_ALL:
+                return "ALL";
+            default:
+                return "Unknown rule " + rule;
+        }
+    }
+
+    /** @hide */
+    public boolean isMatchRuleMobile() {
+        switch (mMatchRule) {
+            case MATCH_MOBILE:
+            case MATCH_MOBILE_WILDCARD:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * Get match rule of the template. See {@code MATCH_*}.
+     */
+    @UnsupportedAppUsage
+    public int getMatchRule() {
+        // Wildcard rules are not exposed. For external callers, convert wildcard rules to
+        // exposed rules before returning.
+        switch (mMatchRule) {
+            case MATCH_MOBILE_WILDCARD:
+                return MATCH_MOBILE;
+            case MATCH_WIFI_WILDCARD:
+                return MATCH_WIFI;
+            default:
+                return mMatchRule;
+        }
+    }
+
+    /**
+     * Get subscriber Id of the template.
+     * @hide
+     */
+    @Nullable
+    @UnsupportedAppUsage
+    public String getSubscriberId() {
+        return mSubscriberId;
+    }
+
+    /**
+     * Get set of subscriber Ids of the template.
+     */
+    @NonNull
+    public Set<String> getSubscriberIds() {
+        return new ArraySet<>(Arrays.asList(mMatchSubscriberIds));
+    }
+
+    /**
+     * Get the set of Wifi Network Keys of the template.
+     * See {@link WifiInfo#getNetworkKey()}.
+     */
+    @NonNull
+    public Set<String> getWifiNetworkKeys() {
+        return new ArraySet<>(Arrays.asList(mMatchWifiNetworkKeys));
+    }
+
+    /** @hide */
+    // TODO: Remove this and replace all callers with {@link #getWifiNetworkKeys()}.
+    @Nullable
+    public String getNetworkId() {
+        return getWifiNetworkKeys().isEmpty() ? null : getWifiNetworkKeys().iterator().next();
+    }
+
+    /**
+     * Get meteredness filter of the template.
+     */
+    @NetworkStats.Meteredness
+    public int getMeteredness() {
+        return mMetered;
+    }
+
+    /**
+     * Get roaming filter of the template.
+     */
+    @NetworkStats.Roaming
+    public int getRoaming() {
+        return mRoaming;
+    }
+
+    /**
+     * Get the default network status filter of the template.
+     */
+    @NetworkStats.DefaultNetwork
+    public int getDefaultNetworkStatus() {
+        return mDefaultNetwork;
+    }
+
+    /**
+     * Get the Radio Access Technology(RAT) type filter of the template.
+     */
+    public int getRatType() {
+        return mRatType;
+    }
+
+    /**
+     * Get the OEM managed filter of the template. See {@code OEM_MANAGED_*} or
+     * {@code android.net.NetworkIdentity#OEM_*}.
+     */
+    @OemManaged
+    public int getOemManaged() {
+        return mOemManaged;
+    }
+
+    /**
+     * Test if given {@link NetworkIdentity} matches this template.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public boolean matches(@NonNull NetworkIdentity ident) {
+        Objects.requireNonNull(ident);
+        if (!matchesMetered(ident)) return false;
+        if (!matchesRoaming(ident)) return false;
+        if (!matchesDefaultNetwork(ident)) return false;
+        if (!matchesOemNetwork(ident)) return false;
+
+        switch (mMatchRule) {
+            case MATCH_MOBILE:
+                return matchesMobile(ident);
+            case MATCH_WIFI:
+                return matchesWifi(ident);
+            case MATCH_ETHERNET:
+                return matchesEthernet(ident);
+            case MATCH_MOBILE_WILDCARD:
+                return matchesMobileWildcard(ident);
+            case MATCH_WIFI_WILDCARD:
+                return matchesWifiWildcard(ident);
+            case MATCH_BLUETOOTH:
+                return matchesBluetooth(ident);
+            case MATCH_PROXY:
+                return matchesProxy(ident);
+            case MATCH_CARRIER:
+                return matchesCarrier(ident);
+            default:
+                // We have no idea what kind of network template we are, so we
+                // just claim not to match anything.
+                return false;
+        }
+    }
+
+    private boolean matchesMetered(NetworkIdentity ident) {
+        return (mMetered == METERED_ALL)
+            || (mMetered == METERED_YES && ident.mMetered)
+            || (mMetered == METERED_NO && !ident.mMetered);
+    }
+
+    private boolean matchesRoaming(NetworkIdentity ident) {
+        return (mRoaming == ROAMING_ALL)
+            || (mRoaming == ROAMING_YES && ident.mRoaming)
+            || (mRoaming == ROAMING_NO && !ident.mRoaming);
+    }
+
+    private boolean matchesDefaultNetwork(NetworkIdentity ident) {
+        return (mDefaultNetwork == DEFAULT_NETWORK_ALL)
+            || (mDefaultNetwork == DEFAULT_NETWORK_YES && ident.mDefaultNetwork)
+            || (mDefaultNetwork == DEFAULT_NETWORK_NO && !ident.mDefaultNetwork);
+    }
+
+    private boolean matchesOemNetwork(NetworkIdentity ident) {
+        return (mOemManaged == OEM_MANAGED_ALL)
+            || (mOemManaged == OEM_MANAGED_YES
+                    && ident.mOemManaged != OEM_NONE)
+            || (mOemManaged == ident.mOemManaged);
+    }
+
+    private boolean matchesCollapsedRatType(NetworkIdentity ident) {
+        return mRatType == NETWORK_TYPE_ALL
+                || NetworkStatsManager.getCollapsedRatType(mRatType)
+                == NetworkStatsManager.getCollapsedRatType(ident.mRatType);
+    }
+
+    /**
+     * Check if this template matches {@code subscriberId}. Returns true if this
+     * template was created with {@code SUBSCRIBER_ID_MATCH_RULE_ALL}, or with a
+     * {@code mMatchSubscriberIds} array that contains {@code subscriberId}.
+     *
+     * @hide
+     */
+    public boolean matchesSubscriberId(@Nullable String subscriberId) {
+        return mSubscriberIdMatchRule == NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_ALL
+                || CollectionUtils.contains(mMatchSubscriberIds, subscriberId);
+    }
+
+    /**
+     * Check if network matches key of the wifi network.
+     * Returns true when the key matches, or when {@code mMatchWifiNetworkKeys} is
+     * empty.
+     *
+     * @param wifiNetworkKey key of the wifi network. see {@link WifiInfo#getNetworkKey()}
+     *                  to know details about the key.
+     */
+    private boolean matchesWifiNetworkKey(@NonNull String wifiNetworkKey) {
+        Objects.requireNonNull(wifiNetworkKey);
+        return CollectionUtils.isEmpty(mMatchWifiNetworkKeys)
+                || CollectionUtils.contains(mMatchWifiNetworkKeys, wifiNetworkKey);
+    }
+
+    /**
+     * Check if mobile network matches IMSI.
+     */
+    private boolean matchesMobile(NetworkIdentity ident) {
+        if (ident.mType == TYPE_WIMAX) {
+            // TODO: consider matching against WiMAX subscriber identity
+            return true;
+        } else {
+            return ident.mType == TYPE_MOBILE && !CollectionUtils.isEmpty(mMatchSubscriberIds)
+                    && CollectionUtils.contains(mMatchSubscriberIds, ident.mSubscriberId)
+                    && matchesCollapsedRatType(ident);
+        }
+    }
+
+    /**
+     * Check if matches Wi-Fi network template.
+     */
+    private boolean matchesWifi(NetworkIdentity ident) {
+        switch (ident.mType) {
+            case TYPE_WIFI:
+                return matchesSubscriberId(ident.mSubscriberId)
+                        && matchesWifiNetworkKey(ident.mWifiNetworkKey);
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * Check if matches Ethernet network template.
+     */
+    private boolean matchesEthernet(NetworkIdentity ident) {
+        if (ident.mType == TYPE_ETHERNET) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Check if matches carrier network. The carrier networks means it includes the subscriberId.
+     */
+    private boolean matchesCarrier(NetworkIdentity ident) {
+        return ident.mSubscriberId != null
+                && !CollectionUtils.isEmpty(mMatchSubscriberIds)
+                && CollectionUtils.contains(mMatchSubscriberIds, ident.mSubscriberId);
+    }
+
+    private boolean matchesMobileWildcard(NetworkIdentity ident) {
+        if (ident.mType == TYPE_WIMAX) {
+            return true;
+        } else {
+            return ident.mType == TYPE_MOBILE && matchesCollapsedRatType(ident);
+        }
+    }
+
+    private boolean matchesWifiWildcard(NetworkIdentity ident) {
+        switch (ident.mType) {
+            case TYPE_WIFI:
+            case TYPE_WIFI_P2P:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * Check if matches Bluetooth network template.
+     */
+    private boolean matchesBluetooth(NetworkIdentity ident) {
+        if (ident.mType == TYPE_BLUETOOTH) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Check if matches Proxy network template.
+     */
+    private boolean matchesProxy(NetworkIdentity ident) {
+        return ident.mType == TYPE_PROXY;
+    }
+
+    private static String getMatchRuleName(int matchRule) {
+        switch (matchRule) {
+            case MATCH_MOBILE:
+                return "MOBILE";
+            case MATCH_WIFI:
+                return "WIFI";
+            case MATCH_ETHERNET:
+                return "ETHERNET";
+            case MATCH_MOBILE_WILDCARD:
+                return "MOBILE_WILDCARD";
+            case MATCH_WIFI_WILDCARD:
+                return "WIFI_WILDCARD";
+            case MATCH_BLUETOOTH:
+                return "BLUETOOTH";
+            case MATCH_PROXY:
+                return "PROXY";
+            case MATCH_CARRIER:
+                return "CARRIER";
+            default:
+                return "UNKNOWN(" + matchRule + ")";
+        }
+    }
+
+    private static String getOemManagedNames(int oemManaged) {
+        switch (oemManaged) {
+            case OEM_MANAGED_ALL:
+                return "OEM_MANAGED_ALL";
+            case OEM_MANAGED_NO:
+                return "OEM_MANAGED_NO";
+            case OEM_MANAGED_YES:
+                return "OEM_MANAGED_YES";
+            default:
+                return NetworkIdentity.getOemManagedNames(oemManaged);
+        }
+    }
+
+    /**
+     * Examine the given template and normalize it.
+     * We pick the "lowest" merged subscriber as the primary
+     * for key purposes, and expand the template to match all other merged
+     * subscribers.
+     * <p>
+     * For example, given an incoming template matching B, and the currently
+     * active merge set [A,B], we'd return a new template that primarily matches
+     * A, but also matches B.
+     * TODO: remove and use {@link #normalize(NetworkTemplate, List)}.
+     *
+     * @hide
+     */
+    @UnsupportedAppUsage
+    public static NetworkTemplate normalize(NetworkTemplate template, String[] merged) {
+        return normalize(template, Arrays.<String[]>asList(merged));
+    }
+
+    /**
+     * Examine the given template and normalize it.
+     * We pick the "lowest" merged subscriber as the primary
+     * for key purposes, and expand the template to match all other merged
+     * subscribers.
+     *
+     * There can be multiple merged subscriberIds for multi-SIM devices.
+     *
+     * <p>
+     * For example, given an incoming template matching B, and the currently
+     * active merge set [A,B], we'd return a new template that primarily matches
+     * A, but also matches B.
+     *
+     * @hide
+     */
+    // TODO: @SystemApi when ready.
+    public static NetworkTemplate normalize(NetworkTemplate template, List<String[]> mergedList) {
+        // Now there are several types of network which uses SubscriberId to store network
+        // information. For instances:
+        // The TYPE_WIFI with subscriberId means that it is a merged carrier wifi network.
+        // The TYPE_CARRIER means that the network associate to specific carrier network.
+
+        if (template.mSubscriberId == null) return template;
+
+        for (String[] merged : mergedList) {
+            if (CollectionUtils.contains(merged, template.mSubscriberId)) {
+                // Requested template subscriber is part of the merge group; return
+                // a template that matches all merged subscribers.
+                final String[] matchWifiNetworkKeys = template.mMatchWifiNetworkKeys;
+                return new NetworkTemplate(template.mMatchRule, merged[0], merged,
+                        CollectionUtils.isEmpty(matchWifiNetworkKeys)
+                                ? null : matchWifiNetworkKeys[0]);
+            }
+        }
+
+        return template;
+    }
+
+    @UnsupportedAppUsage
+    public static final @android.annotation.NonNull Creator<NetworkTemplate> CREATOR = new Creator<NetworkTemplate>() {
+        @Override
+        public NetworkTemplate createFromParcel(Parcel in) {
+            return new NetworkTemplate(in);
+        }
+
+        @Override
+        public NetworkTemplate[] newArray(int size) {
+            return new NetworkTemplate[size];
+        }
+    };
+
+    /**
+     * Builder class for NetworkTemplate.
+     */
+    public static final class Builder {
+        private final int mMatchRule;
+        // Use a SortedSet to provide a deterministic order when fetching the first one.
+        @NonNull
+        private final SortedSet<String> mMatchSubscriberIds =
+                new TreeSet<>(Comparator.nullsFirst(Comparator.naturalOrder()));
+        @NonNull
+        private final SortedSet<String> mMatchWifiNetworkKeys = new TreeSet<>();
+
+        // Matches for the NetworkStats constants METERED_*, ROAMING_* and DEFAULT_NETWORK_*.
+        private int mMetered;
+        private int mRoaming;
+        private int mDefaultNetwork;
+        private int mRatType;
+
+        // Bitfield containing OEM network properties {@code NetworkIdentity#OEM_*}.
+        private int mOemManaged;
+
+        /**
+         * Creates a new Builder with given match rule to construct NetworkTemplate objects.
+         *
+         * @param matchRule the match rule of the template, see {@code MATCH_*}.
+         */
+        public Builder(@TemplateMatchRule final int matchRule) {
+            assertRequestableMatchRule(matchRule);
+            // Initialize members with default values.
+            mMatchRule = matchRule;
+            mMetered = METERED_ALL;
+            mRoaming = ROAMING_ALL;
+            mDefaultNetwork = DEFAULT_NETWORK_ALL;
+            mRatType = NETWORK_TYPE_ALL;
+            mOemManaged = OEM_MANAGED_ALL;
+        }
+
+        /**
+         * Set the Subscriber Ids. Calling this function with an empty set represents
+         * the intention of matching any Subscriber Ids.
+         *
+         * @param subscriberIds the list of Subscriber Ids.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setSubscriberIds(@NonNull Set<String> subscriberIds) {
+            Objects.requireNonNull(subscriberIds);
+            mMatchSubscriberIds.clear();
+            mMatchSubscriberIds.addAll(subscriberIds);
+            return this;
+        }
+
+        /**
+         * Set the Wifi Network Keys. Calling this function with an empty set represents
+         * the intention of matching any Wifi Network Key.
+         *
+         * @param wifiNetworkKeys the list of Wifi Network Key,
+         *                        see {@link WifiInfo#getNetworkKey()}.
+         *                        Or an empty list to match all networks.
+         *                        Note that {@code getNetworkKey()} might get null key
+         *                        when wifi disconnects. However, the caller should never invoke
+         *                        this function with a null Wifi Network Key since such statistics
+         *                        never exists.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setWifiNetworkKeys(@NonNull Set<String> wifiNetworkKeys) {
+            Objects.requireNonNull(wifiNetworkKeys);
+            for (String key : wifiNetworkKeys) {
+                if (key == null) {
+                    throw new IllegalArgumentException("Null is not a valid key");
+                }
+            }
+            mMatchWifiNetworkKeys.clear();
+            mMatchWifiNetworkKeys.addAll(wifiNetworkKeys);
+            return this;
+        }
+
+        /**
+         * Set the meteredness filter.
+         *
+         * @param metered the meteredness filter.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setMeteredness(@NetworkStats.Meteredness int metered) {
+            mMetered = metered;
+            return this;
+        }
+
+        /**
+         * Set the roaming filter.
+         *
+         * @param roaming the roaming filter.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setRoaming(@NetworkStats.Roaming int roaming) {
+            mRoaming = roaming;
+            return this;
+        }
+
+        /**
+         * Set the default network status filter.
+         *
+         * @param defaultNetwork the default network status filter.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setDefaultNetworkStatus(@NetworkStats.DefaultNetwork int defaultNetwork) {
+            mDefaultNetwork = defaultNetwork;
+            return this;
+        }
+
+        /**
+         * Set the Radio Access Technology(RAT) type filter.
+         *
+         * @param ratType the Radio Access Technology(RAT) type filter. Use
+         *                {@link #NETWORK_TYPE_ALL} to include all network types when filtering.
+         *                See {@code TelephonyManager.NETWORK_TYPE_*}.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setRatType(int ratType) {
+            // Input will be validated with the match rule when building the template.
+            mRatType = ratType;
+            return this;
+        }
+
+        /**
+         * Set the OEM managed filter.
+         *
+         * @param oemManaged the match rule to match different type of OEM managed network or
+         *                   unmanaged networks. See {@code OEM_MANAGED_*}.
+         * @return this builder.
+         */
+        @NonNull
+        public Builder setOemManaged(@OemManaged int oemManaged) {
+            mOemManaged = oemManaged;
+            return this;
+        }
+
+        /**
+         * Check whether the match rule is requestable.
+         *
+         * @param matchRule the target match rule to be checked.
+         */
+        private static void assertRequestableMatchRule(final int matchRule) {
+            if (!isKnownMatchRule(matchRule)
+                    || matchRule == MATCH_PROXY
+                    || matchRule == MATCH_MOBILE_WILDCARD
+                    || matchRule == MATCH_WIFI_WILDCARD) {
+                throw new IllegalArgumentException("Invalid match rule: "
+                        + getMatchRuleName(matchRule));
+            }
+        }
+
+        private void assertRequestableParameters() {
+            validateWifiNetworkKeys();
+            // TODO: Check all the input are legitimate.
+        }
+
+        private void validateWifiNetworkKeys() {
+            if (mMatchRule != MATCH_WIFI && !mMatchWifiNetworkKeys.isEmpty()) {
+                throw new IllegalArgumentException("Trying to build non wifi match rule: "
+                        + mMatchRule + " with wifi network keys");
+            }
+        }
+
+        /**
+         * For backward compatibility, deduce match rule to a wildcard match rule
+         * if the Subscriber Ids are empty.
+         */
+        private int getWildcardDeducedMatchRule() {
+            if (mMatchRule == MATCH_MOBILE && mMatchSubscriberIds.isEmpty()) {
+                return MATCH_MOBILE_WILDCARD;
+            } else if (mMatchRule == MATCH_WIFI && mMatchSubscriberIds.isEmpty()
+                    && mMatchWifiNetworkKeys.isEmpty()) {
+                return MATCH_WIFI_WILDCARD;
+            }
+            return mMatchRule;
+        }
+
+        /**
+         * Builds the instance of the NetworkTemplate.
+         *
+         * @return the built instance of NetworkTemplate.
+         */
+        @NonNull
+        public NetworkTemplate build() {
+            assertRequestableParameters();
+            final int subscriberIdMatchRule = mMatchSubscriberIds.isEmpty()
+                    ? NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_ALL
+                    : NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT;
+            return new NetworkTemplate(getWildcardDeducedMatchRule(),
+                    mMatchSubscriberIds.isEmpty() ? null : mMatchSubscriberIds.iterator().next(),
+                    mMatchSubscriberIds.toArray(new String[0]),
+                    mMatchWifiNetworkKeys.toArray(new String[0]), mMetered, mRoaming,
+                    mDefaultNetwork, mRatType, mOemManaged, subscriberIdMatchRule);
+        }
+    }
+}
diff --git a/framework-t/src/android/net/TrafficStats.java b/framework-t/src/android/net/TrafficStats.java
new file mode 100644
index 0000000..dc4ac55
--- /dev/null
+++ b/framework-t/src/android/net/TrafficStats.java
@@ -0,0 +1,1148 @@
+/*
+ * Copyright (C) 2007 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+
+import android.annotation.NonNull;
+import android.annotation.SuppressLint;
+import android.annotation.SystemApi;
+import android.annotation.TestApi;
+import android.app.DownloadManager;
+import android.app.backup.BackupManager;
+import android.app.usage.NetworkStatsManager;
+import android.compat.annotation.UnsupportedAppUsage;
+import android.content.Context;
+import android.media.MediaPlayer;
+import android.os.Binder;
+import android.os.Build;
+import android.os.RemoteException;
+import android.os.StrictMode;
+import android.util.Log;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.net.DatagramSocket;
+import java.net.Socket;
+import java.net.SocketException;
+
+/**
+ * Class that provides network traffic statistics. These statistics include
+ * bytes transmitted and received and network packets transmitted and received,
+ * over all interfaces, over the mobile interface, and on a per-UID basis.
+ * <p>
+ * These statistics may not be available on all platforms. If the statistics are
+ * not supported by this device, {@link #UNSUPPORTED} will be returned.
+ * <p>
+ * Note that the statistics returned by this class reset and start from zero
+ * after every reboot. To access more robust historical network statistics data,
+ * use {@link NetworkStatsManager} instead.
+ */
+public class TrafficStats {
+    static {
+        System.loadLibrary("framework-connectivity-tiramisu-jni");
+    }
+
+    private static final String TAG = TrafficStats.class.getSimpleName();
+    /**
+     * The return value to indicate that the device does not support the statistic.
+     */
+    public final static int UNSUPPORTED = -1;
+
+    /** @hide @deprecated use {@code DataUnit} instead to clarify SI-vs-IEC */
+    @Deprecated
+    public static final long KB_IN_BYTES = 1024;
+    /** @hide @deprecated use {@code DataUnit} instead to clarify SI-vs-IEC */
+    @Deprecated
+    public static final long MB_IN_BYTES = KB_IN_BYTES * 1024;
+    /** @hide @deprecated use {@code DataUnit} instead to clarify SI-vs-IEC */
+    @Deprecated
+    public static final long GB_IN_BYTES = MB_IN_BYTES * 1024;
+    /** @hide @deprecated use {@code DataUnit} instead to clarify SI-vs-IEC */
+    @Deprecated
+    public static final long TB_IN_BYTES = GB_IN_BYTES * 1024;
+    /** @hide @deprecated use {@code DataUnit} instead to clarify SI-vs-IEC */
+    @Deprecated
+    public static final long PB_IN_BYTES = TB_IN_BYTES * 1024;
+
+    /**
+     * Special UID value used when collecting {@link NetworkStatsHistory} for
+     * removed applications.
+     *
+     * @hide
+     */
+    public static final int UID_REMOVED = -4;
+
+    /**
+     * Special UID value used when collecting {@link NetworkStatsHistory} for
+     * tethering traffic.
+     *
+     * @hide
+     */
+    public static final int UID_TETHERING = NetworkStats.UID_TETHERING;
+
+    /**
+     * Tag values in this range are reserved for the network stack. The network stack is
+     * running as UID {@link android.os.Process.NETWORK_STACK_UID} when in the mainline
+     * module separate process, and as the system UID otherwise.
+     */
+    /** @hide */
+    @SystemApi
+    public static final int TAG_NETWORK_STACK_RANGE_START = 0xFFFFFD00;
+    /** @hide */
+    @SystemApi
+    public static final int TAG_NETWORK_STACK_RANGE_END = 0xFFFFFEFF;
+
+    /**
+     * Tags between 0xFFFFFF00 and 0xFFFFFFFF are reserved and used internally by system services
+     * like DownloadManager when performing traffic on behalf of an application.
+     */
+    // Please note there is no enforcement of these constants, so do not rely on them to
+    // determine that the caller is a system caller.
+    /** @hide */
+    @SystemApi
+    public static final int TAG_SYSTEM_IMPERSONATION_RANGE_START = 0xFFFFFF00;
+    /** @hide */
+    @SystemApi
+    public static final int TAG_SYSTEM_IMPERSONATION_RANGE_END = 0xFFFFFF0F;
+
+    /**
+     * Tag values between these ranges are reserved for the network stack to do traffic
+     * on behalf of applications. It is a subrange of the range above.
+     */
+    /** @hide */
+    @SystemApi
+    public static final int TAG_NETWORK_STACK_IMPERSONATION_RANGE_START = 0xFFFFFF80;
+    /** @hide */
+    @SystemApi
+    public static final int TAG_NETWORK_STACK_IMPERSONATION_RANGE_END = 0xFFFFFF8F;
+
+    /**
+     * Default tag value for {@link DownloadManager} traffic.
+     *
+     * @hide
+     */
+    public static final int TAG_SYSTEM_DOWNLOAD = 0xFFFFFF01;
+
+    /**
+     * Default tag value for {@link MediaPlayer} traffic.
+     *
+     * @hide
+     */
+    public static final int TAG_SYSTEM_MEDIA = 0xFFFFFF02;
+
+    /**
+     * Default tag value for {@link BackupManager} backup traffic; that is,
+     * traffic from the device to the storage backend.
+     *
+     * @hide
+     */
+    public static final int TAG_SYSTEM_BACKUP = 0xFFFFFF03;
+
+    /**
+     * Default tag value for {@link BackupManager} restore traffic; that is,
+     * app data retrieved from the storage backend at install time.
+     *
+     * @hide
+     */
+    public static final int TAG_SYSTEM_RESTORE = 0xFFFFFF04;
+
+    /**
+     * Default tag value for code (typically APKs) downloaded by an app store on
+     * behalf of the app, such as updates.
+     *
+     * @hide
+     */
+    public static final int TAG_SYSTEM_APP = 0xFFFFFF05;
+
+    // TODO : remove this constant when Wifi code is updated
+    /** @hide */
+    public static final int TAG_SYSTEM_PROBE = 0xFFFFFF42;
+
+    private static INetworkStatsService sStatsService;
+
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 130143562)
+    private synchronized static INetworkStatsService getStatsService() {
+        if (sStatsService == null) {
+            throw new IllegalStateException("TrafficStats not initialized, uid="
+                    + Binder.getCallingUid());
+        }
+        return sStatsService;
+    }
+
+    /**
+     * Snapshot of {@link NetworkStats} when the currently active profiling
+     * session started, or {@code null} if no session active.
+     *
+     * @see #startDataProfiling(Context)
+     * @see #stopDataProfiling(Context)
+     */
+    private static NetworkStats sActiveProfilingStart;
+
+    private static Object sProfilingLock = new Object();
+
+    private static final String LOOPBACK_IFACE = "lo";
+
+    /**
+     * Initialization {@link TrafficStats} with the context, to
+     * allow {@link TrafficStats} to fetch the needed binder.
+     *
+     * @param context a long-lived context, such as the application context or system
+     *                server context.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    @SuppressLint("VisiblySynchronized")
+    public static synchronized void init(@NonNull final Context context) {
+        if (sStatsService != null) {
+            throw new IllegalStateException("TrafficStats is already initialized, uid="
+                    + Binder.getCallingUid());
+        }
+        final NetworkStatsManager statsManager =
+                context.getSystemService(NetworkStatsManager.class);
+        if (statsManager == null) {
+            // TODO: Currently Process.isSupplemental is not working yet, because it depends on
+            //  process to run in a certain UID range, which is not true for now. Change this
+            //  to Log.wtf once Process.isSupplemental is ready.
+            Log.e(TAG, "TrafficStats not initialized, uid=" + Binder.getCallingUid());
+            return;
+        }
+        sStatsService = statsManager.getBinder();
+    }
+
+    /**
+     * Attach the socket tagger implementation to the current process, to
+     * get notified when a socket's {@link FileDescriptor} is assigned to
+     * a thread. See {@link SocketTagger#set(SocketTagger)}.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static void attachSocketTagger() {
+        dalvik.system.SocketTagger.set(new SocketTagger());
+    }
+
+    private static class SocketTagger extends dalvik.system.SocketTagger {
+
+        // TODO: set to false
+        private static final boolean LOGD = true;
+
+        SocketTagger() {
+        }
+
+        @Override
+        public void tag(FileDescriptor fd) throws SocketException {
+            final UidTag tagInfo = sThreadUidTag.get();
+            if (LOGD) {
+                Log.d(TAG, "tagSocket(" + fd.getInt$() + ") with statsTag=0x"
+                        + Integer.toHexString(tagInfo.tag) + ", statsUid=" + tagInfo.uid);
+            }
+            if (tagInfo.tag == -1) {
+                StrictMode.noteUntaggedSocket();
+            }
+
+            if (tagInfo.tag == -1 && tagInfo.uid == -1) return;
+            final int errno = native_tagSocketFd(fd, tagInfo.tag, tagInfo.uid);
+            if (errno < 0) {
+                Log.i(TAG, "tagSocketFd(" + fd.getInt$() + ", "
+                        + tagInfo.tag + ", "
+                        + tagInfo.uid + ") failed with errno" + errno);
+            }
+        }
+
+        @Override
+        public void untag(FileDescriptor fd) throws SocketException {
+            if (LOGD) {
+                Log.i(TAG, "untagSocket(" + fd.getInt$() + ")");
+            }
+
+            final UidTag tagInfo = sThreadUidTag.get();
+            if (tagInfo.tag == -1 && tagInfo.uid == -1) return;
+
+            final int errno = native_untagSocketFd(fd);
+            if (errno < 0) {
+                Log.w(TAG, "untagSocket(" + fd.getInt$() + ") failed with errno " + errno);
+            }
+        }
+    }
+
+    private static native int native_tagSocketFd(FileDescriptor fd, int tag, int uid);
+    private static native int native_untagSocketFd(FileDescriptor fd);
+
+    private static class UidTag {
+        public int tag = -1;
+        public int uid = -1;
+    }
+
+    private static ThreadLocal<UidTag> sThreadUidTag = new ThreadLocal<UidTag>() {
+        @Override
+        protected UidTag initialValue() {
+            return new UidTag();
+        }
+    };
+
+    /**
+     * Set active tag to use when accounting {@link Socket} traffic originating
+     * from the current thread. Only one active tag per thread is supported.
+     * <p>
+     * Changes only take effect during subsequent calls to
+     * {@link #tagSocket(Socket)}.
+     * <p>
+     * Tags between {@code 0xFFFFFF00} and {@code 0xFFFFFFFF} are reserved and
+     * used internally by system services like {@link DownloadManager} when
+     * performing traffic on behalf of an application.
+     *
+     * @see #clearThreadStatsTag()
+     */
+    public static void setThreadStatsTag(int tag) {
+        getAndSetThreadStatsTag(tag);
+    }
+
+    /**
+     * Set active tag to use when accounting {@link Socket} traffic originating
+     * from the current thread. Only one active tag per thread is supported.
+     * <p>
+     * Changes only take effect during subsequent calls to
+     * {@link #tagSocket(Socket)}.
+     * <p>
+     * Tags between {@code 0xFFFFFF00} and {@code 0xFFFFFFFF} are reserved and
+     * used internally by system services like {@link DownloadManager} when
+     * performing traffic on behalf of an application.
+     *
+     * @return the current tag for the calling thread, which can be used to
+     *         restore any existing values after a nested operation is finished
+     */
+    public static int getAndSetThreadStatsTag(int tag) {
+        final int old = sThreadUidTag.get().tag;
+        sThreadUidTag.get().tag = tag;
+        return old;
+    }
+
+    /**
+     * Set active tag to use when accounting {@link Socket} traffic originating
+     * from the current thread. The tag used internally is well-defined to
+     * distinguish all backup-related traffic.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static void setThreadStatsTagBackup() {
+        setThreadStatsTag(TAG_SYSTEM_BACKUP);
+    }
+
+    /**
+     * Set active tag to use when accounting {@link Socket} traffic originating
+     * from the current thread. The tag used internally is well-defined to
+     * distinguish all restore-related traffic.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static void setThreadStatsTagRestore() {
+        setThreadStatsTag(TAG_SYSTEM_RESTORE);
+    }
+
+    /**
+     * Set active tag to use when accounting {@link Socket} traffic originating
+     * from the current thread. The tag used internally is well-defined to
+     * distinguish all code (typically APKs) downloaded by an app store on
+     * behalf of the app, such as updates.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static void setThreadStatsTagApp() {
+        setThreadStatsTag(TAG_SYSTEM_APP);
+    }
+
+    /**
+     * Set active tag to use when accounting {@link Socket} traffic originating
+     * from the current thread. The tag used internally is well-defined to
+     * distinguish all download provider traffic.
+     *
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static void setThreadStatsTagDownload() {
+        setThreadStatsTag(TAG_SYSTEM_DOWNLOAD);
+    }
+
+    /**
+     * Get the active tag used when accounting {@link Socket} traffic originating
+     * from the current thread. Only one active tag per thread is supported.
+     * {@link #tagSocket(Socket)}.
+     *
+     * @see #setThreadStatsTag(int)
+     */
+    public static int getThreadStatsTag() {
+        return sThreadUidTag.get().tag;
+    }
+
+    /**
+     * Clear any active tag set to account {@link Socket} traffic originating
+     * from the current thread.
+     *
+     * @see #setThreadStatsTag(int)
+     */
+    public static void clearThreadStatsTag() {
+        sThreadUidTag.get().tag = -1;
+    }
+
+    /**
+     * Set specific UID to use when accounting {@link Socket} traffic
+     * originating from the current thread. Designed for use when performing an
+     * operation on behalf of another application, or when another application
+     * is performing operations on your behalf.
+     * <p>
+     * Any app can <em>accept</em> blame for traffic performed on a socket
+     * originally created by another app by calling this method with the
+     * {@link android.system.Os#getuid()} value. However, only apps holding the
+     * {@code android.Manifest.permission#UPDATE_DEVICE_STATS} permission may
+     * <em>assign</em> blame to another UIDs.
+     * <p>
+     * Changes only take effect during subsequent calls to
+     * {@link #tagSocket(Socket)}.
+     */
+    @SuppressLint("RequiresPermission")
+    public static void setThreadStatsUid(int uid) {
+        sThreadUidTag.get().uid = uid;
+    }
+
+    /**
+     * Get the active UID used when accounting {@link Socket} traffic originating
+     * from the current thread. Only one active tag per thread is supported.
+     * {@link #tagSocket(Socket)}.
+     *
+     * @see #setThreadStatsUid(int)
+     */
+    public static int getThreadStatsUid() {
+        return sThreadUidTag.get().uid;
+    }
+
+    /**
+     * Set specific UID to use when accounting {@link Socket} traffic
+     * originating from the current thread as the calling UID. Designed for use
+     * when another application is performing operations on your behalf.
+     * <p>
+     * Changes only take effect during subsequent calls to
+     * {@link #tagSocket(Socket)}.
+     *
+     * @removed
+     * @deprecated use {@link #setThreadStatsUid(int)} instead.
+     */
+    @Deprecated
+    public static void setThreadStatsUidSelf() {
+        setThreadStatsUid(android.os.Process.myUid());
+    }
+
+    /**
+     * Clear any active UID set to account {@link Socket} traffic originating
+     * from the current thread.
+     *
+     * @see #setThreadStatsUid(int)
+     */
+    @SuppressLint("RequiresPermission")
+    public static void clearThreadStatsUid() {
+        setThreadStatsUid(-1);
+    }
+
+    /**
+     * Tag the given {@link Socket} with any statistics parameters active for
+     * the current thread. Subsequent calls always replace any existing
+     * parameters. When finished, call {@link #untagSocket(Socket)} to remove
+     * statistics parameters.
+     *
+     * @see #setThreadStatsTag(int)
+     */
+    public static void tagSocket(@NonNull Socket socket) throws SocketException {
+        SocketTagger.get().tag(socket);
+    }
+
+    /**
+     * Remove any statistics parameters from the given {@link Socket}.
+     * <p>
+     * In Android 8.1 (API level 27) and lower, a socket is automatically
+     * untagged when it's sent to another process using binder IPC with a
+     * {@code ParcelFileDescriptor} container. In Android 9.0 (API level 28)
+     * and higher, the socket tag is kept when the socket is sent to another
+     * process using binder IPC. You can mimic the previous behavior by
+     * calling {@code untagSocket()} before sending the socket to another
+     * process.
+     */
+    public static void untagSocket(@NonNull Socket socket) throws SocketException {
+        SocketTagger.get().untag(socket);
+    }
+
+    /**
+     * Tag the given {@link DatagramSocket} with any statistics parameters
+     * active for the current thread. Subsequent calls always replace any
+     * existing parameters. When finished, call
+     * {@link #untagDatagramSocket(DatagramSocket)} to remove statistics
+     * parameters.
+     *
+     * @see #setThreadStatsTag(int)
+     */
+    public static void tagDatagramSocket(@NonNull DatagramSocket socket) throws SocketException {
+        SocketTagger.get().tag(socket);
+    }
+
+    /**
+     * Remove any statistics parameters from the given {@link DatagramSocket}.
+     */
+    public static void untagDatagramSocket(@NonNull DatagramSocket socket) throws SocketException {
+        SocketTagger.get().untag(socket);
+    }
+
+    /**
+     * Tag the given {@link FileDescriptor} socket with any statistics
+     * parameters active for the current thread. Subsequent calls always replace
+     * any existing parameters. When finished, call
+     * {@link #untagFileDescriptor(FileDescriptor)} to remove statistics
+     * parameters.
+     *
+     * @see #setThreadStatsTag(int)
+     */
+    public static void tagFileDescriptor(@NonNull FileDescriptor fd) throws IOException {
+        SocketTagger.get().tag(fd);
+    }
+
+    /**
+     * Remove any statistics parameters from the given {@link FileDescriptor}
+     * socket.
+     */
+    public static void untagFileDescriptor(@NonNull FileDescriptor fd) throws IOException {
+        SocketTagger.get().untag(fd);
+    }
+
+    /**
+     * Start profiling data usage for current UID. Only one profiling session
+     * can be active at a time.
+     *
+     * @hide
+     */
+    public static void startDataProfiling(Context context) {
+        synchronized (sProfilingLock) {
+            if (sActiveProfilingStart != null) {
+                throw new IllegalStateException("already profiling data");
+            }
+
+            // take snapshot in time; we calculate delta later
+            sActiveProfilingStart = getDataLayerSnapshotForUid(context);
+        }
+    }
+
+    /**
+     * Stop profiling data usage for current UID.
+     *
+     * @return Detailed {@link NetworkStats} of data that occurred since last
+     *         {@link #startDataProfiling(Context)} call.
+     * @hide
+     */
+    public static NetworkStats stopDataProfiling(Context context) {
+        synchronized (sProfilingLock) {
+            if (sActiveProfilingStart == null) {
+                throw new IllegalStateException("not profiling data");
+            }
+
+            // subtract starting values and return delta
+            final NetworkStats profilingStop = getDataLayerSnapshotForUid(context);
+            final NetworkStats profilingDelta = NetworkStats.subtract(
+                    profilingStop, sActiveProfilingStart, null, null);
+            sActiveProfilingStart = null;
+            return profilingDelta;
+        }
+    }
+
+    /**
+     * Increment count of network operations performed under the accounting tag
+     * currently active on the calling thread. This can be used to derive
+     * bytes-per-operation.
+     *
+     * @param operationCount Number of operations to increment count by.
+     */
+    public static void incrementOperationCount(int operationCount) {
+        final int tag = getThreadStatsTag();
+        incrementOperationCount(tag, operationCount);
+    }
+
+    /**
+     * Increment count of network operations performed under the given
+     * accounting tag. This can be used to derive bytes-per-operation.
+     *
+     * @param tag Accounting tag used in {@link #setThreadStatsTag(int)}.
+     * @param operationCount Number of operations to increment count by.
+     */
+    public static void incrementOperationCount(int tag, int operationCount) {
+        final int uid = android.os.Process.myUid();
+        try {
+            getStatsService().incrementOperationCount(uid, tag, operationCount);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** {@hide} */
+    public static void closeQuietly(INetworkStatsSession session) {
+        // TODO: move to NetworkStatsService once it exists
+        if (session != null) {
+            try {
+                session.close();
+            } catch (RuntimeException rethrown) {
+                throw rethrown;
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+    private static long addIfSupported(long stat) {
+        return (stat == UNSUPPORTED) ? 0 : stat;
+    }
+
+    /**
+     * Return number of packets transmitted across mobile networks since device
+     * boot. Counts packets across all mobile network interfaces, and always
+     * increases monotonically since device boot. Statistics are measured at the
+     * network layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getMobileTxPackets() {
+        long total = 0;
+        for (String iface : getMobileIfaces()) {
+            total += addIfSupported(getTxPackets(iface));
+        }
+        return total;
+    }
+
+    /**
+     * Return number of packets received across mobile networks since device
+     * boot. Counts packets across all mobile network interfaces, and always
+     * increases monotonically since device boot. Statistics are measured at the
+     * network layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getMobileRxPackets() {
+        long total = 0;
+        for (String iface : getMobileIfaces()) {
+            total += addIfSupported(getRxPackets(iface));
+        }
+        return total;
+    }
+
+    /**
+     * Return number of bytes transmitted across mobile networks since device
+     * boot. Counts packets across all mobile network interfaces, and always
+     * increases monotonically since device boot. Statistics are measured at the
+     * network layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getMobileTxBytes() {
+        long total = 0;
+        for (String iface : getMobileIfaces()) {
+            total += addIfSupported(getTxBytes(iface));
+        }
+        return total;
+    }
+
+    /**
+     * Return number of bytes received across mobile networks since device boot.
+     * Counts packets across all mobile network interfaces, and always increases
+     * monotonically since device boot. Statistics are measured at the network
+     * layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getMobileRxBytes() {
+        long total = 0;
+        for (String iface : getMobileIfaces()) {
+            total += addIfSupported(getRxBytes(iface));
+        }
+        return total;
+    }
+
+    /** {@hide} */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public static long getMobileTcpRxPackets() {
+        long total = 0;
+        for (String iface : getMobileIfaces()) {
+            long stat = UNSUPPORTED;
+            try {
+                stat = getStatsService().getIfaceStats(iface, TYPE_TCP_RX_PACKETS);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+            total += addIfSupported(stat);
+        }
+        return total;
+    }
+
+    /** {@hide} */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
+    public static long getMobileTcpTxPackets() {
+        long total = 0;
+        for (String iface : getMobileIfaces()) {
+            long stat = UNSUPPORTED;
+            try {
+                stat = getStatsService().getIfaceStats(iface, TYPE_TCP_TX_PACKETS);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+            total += addIfSupported(stat);
+        }
+        return total;
+    }
+
+    /**
+     * Return the number of packets transmitted on the specified interface since the interface
+     * was created. Statistics are measured at the network layer, so both TCP and
+     * UDP usage are included.
+     *
+     * Note that the returned values are partial statistics that do not count data from several
+     * sources and do not apply several adjustments that are necessary for correctness, such
+     * as adjusting for VPN apps, IPv6-in-IPv4 translation, etc. These values can be used to
+     * determine whether traffic is being transferred on the specific interface but are not a
+     * substitute for the more accurate statistics provided by the {@link NetworkStatsManager}
+     * APIs.
+     *
+     * @param iface The name of the interface.
+     * @return The number of transmitted packets.
+     */
+    public static long getTxPackets(@NonNull String iface) {
+        try {
+            return getStatsService().getIfaceStats(iface, TYPE_TX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return the number of packets received on the specified interface since the interface was
+     * created. Statistics are measured at the network layer, so both TCP
+     * and UDP usage are included.
+     *
+     * Note that the returned values are partial statistics that do not count data from several
+     * sources and do not apply several adjustments that are necessary for correctness, such
+     * as adjusting for VPN apps, IPv6-in-IPv4 translation, etc. These values can be used to
+     * determine whether traffic is being transferred on the specific interface but are not a
+     * substitute for the more accurate statistics provided by the {@link NetworkStatsManager}
+     * APIs.
+     *
+     * @param iface The name of the interface.
+     * @return The number of received packets.
+     */
+    public static long getRxPackets(@NonNull String iface) {
+        try {
+            return getStatsService().getIfaceStats(iface, TYPE_RX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return the number of bytes transmitted on the specified interface since the interface
+     * was created. Statistics are measured at the network layer, so both TCP and
+     * UDP usage are included.
+     *
+     * Note that the returned values are partial statistics that do not count data from several
+     * sources and do not apply several adjustments that are necessary for correctness, such
+     * as adjusting for VPN apps, IPv6-in-IPv4 translation, etc. These values can be used to
+     * determine whether traffic is being transferred on the specific interface but are not a
+     * substitute for the more accurate statistics provided by the {@link NetworkStatsManager}
+     * APIs.
+     *
+     * @param iface The name of the interface.
+     * @return The number of transmitted bytes.
+     */
+    public static long getTxBytes(@NonNull String iface) {
+        try {
+            return getStatsService().getIfaceStats(iface, TYPE_TX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return the number of bytes received on the specified interface since the interface
+     * was created. Statistics are measured at the network layer, so both TCP
+     * and UDP usage are included.
+     *
+     * Note that the returned values are partial statistics that do not count data from several
+     * sources and do not apply several adjustments that are necessary for correctness, such
+     * as adjusting for VPN apps, IPv6-in-IPv4 translation, etc. These values can be used to
+     * determine whether traffic is being transferred on the specific interface but are not a
+     * substitute for the more accurate statistics provided by the {@link NetworkStatsManager}
+     * APIs.
+     *
+     * @param iface The name of the interface.
+     * @return The number of received bytes.
+     */
+    public static long getRxBytes(@NonNull String iface) {
+        try {
+            return getStatsService().getIfaceStats(iface, TYPE_RX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** {@hide} */
+    @TestApi
+    public static long getLoopbackTxPackets() {
+        try {
+            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_TX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** {@hide} */
+    @TestApi
+    public static long getLoopbackRxPackets() {
+        try {
+            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_RX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** {@hide} */
+    @TestApi
+    public static long getLoopbackTxBytes() {
+        try {
+            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_TX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** {@hide} */
+    @TestApi
+    public static long getLoopbackRxBytes() {
+        try {
+            return getStatsService().getIfaceStats(LOOPBACK_IFACE, TYPE_RX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of packets transmitted since device boot. Counts packets
+     * across all network interfaces, and always increases monotonically since
+     * device boot. Statistics are measured at the network layer, so they
+     * include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getTotalTxPackets() {
+        try {
+            return getStatsService().getTotalStats(TYPE_TX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of packets received since device boot. Counts packets
+     * across all network interfaces, and always increases monotonically since
+     * device boot. Statistics are measured at the network layer, so they
+     * include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getTotalRxPackets() {
+        try {
+            return getStatsService().getTotalStats(TYPE_RX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of bytes transmitted since device boot. Counts packets
+     * across all network interfaces, and always increases monotonically since
+     * device boot. Statistics are measured at the network layer, so they
+     * include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getTotalTxBytes() {
+        try {
+            return getStatsService().getTotalStats(TYPE_TX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of bytes received since device boot. Counts packets across
+     * all network interfaces, and always increases monotonically since device
+     * boot. Statistics are measured at the network layer, so they include both
+     * TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     */
+    public static long getTotalRxBytes() {
+        try {
+            return getStatsService().getTotalStats(TYPE_RX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of bytes transmitted by the given UID since device boot.
+     * Counts packets across all network interfaces, and always increases
+     * monotonically since device boot. Statistics are measured at the network
+     * layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may
+     * return {@link #UNSUPPORTED} on devices where statistics aren't available.
+     * <p>
+     * Starting in {@link android.os.Build.VERSION_CODES#N} this will only
+     * report traffic statistics for the calling UID. It will return
+     * {@link #UNSUPPORTED} for all other UIDs for privacy reasons. To access
+     * historical network statistics belonging to other UIDs, use
+     * {@link NetworkStatsManager}.
+     *
+     * @see android.os.Process#myUid()
+     * @see android.content.pm.ApplicationInfo#uid
+     */
+    public static long getUidTxBytes(int uid) {
+        try {
+            return getStatsService().getUidStats(uid, TYPE_TX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of bytes received by the given UID since device boot.
+     * Counts packets across all network interfaces, and always increases
+     * monotonically since device boot. Statistics are measured at the network
+     * layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may return
+     * {@link #UNSUPPORTED} on devices where statistics aren't available.
+     * <p>
+     * Starting in {@link android.os.Build.VERSION_CODES#N} this will only
+     * report traffic statistics for the calling UID. It will return
+     * {@link #UNSUPPORTED} for all other UIDs for privacy reasons. To access
+     * historical network statistics belonging to other UIDs, use
+     * {@link NetworkStatsManager}.
+     *
+     * @see android.os.Process#myUid()
+     * @see android.content.pm.ApplicationInfo#uid
+     */
+    public static long getUidRxBytes(int uid) {
+        try {
+            return getStatsService().getUidStats(uid, TYPE_RX_BYTES);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of packets transmitted by the given UID since device boot.
+     * Counts packets across all network interfaces, and always increases
+     * monotonically since device boot. Statistics are measured at the network
+     * layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may return
+     * {@link #UNSUPPORTED} on devices where statistics aren't available.
+     * <p>
+     * Starting in {@link android.os.Build.VERSION_CODES#N} this will only
+     * report traffic statistics for the calling UID. It will return
+     * {@link #UNSUPPORTED} for all other UIDs for privacy reasons. To access
+     * historical network statistics belonging to other UIDs, use
+     * {@link NetworkStatsManager}.
+     *
+     * @see android.os.Process#myUid()
+     * @see android.content.pm.ApplicationInfo#uid
+     */
+    public static long getUidTxPackets(int uid) {
+        try {
+            return getStatsService().getUidStats(uid, TYPE_TX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return number of packets received by the given UID since device boot.
+     * Counts packets across all network interfaces, and always increases
+     * monotonically since device boot. Statistics are measured at the network
+     * layer, so they include both TCP and UDP usage.
+     * <p>
+     * Before {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2}, this may return
+     * {@link #UNSUPPORTED} on devices where statistics aren't available.
+     * <p>
+     * Starting in {@link android.os.Build.VERSION_CODES#N} this will only
+     * report traffic statistics for the calling UID. It will return
+     * {@link #UNSUPPORTED} for all other UIDs for privacy reasons. To access
+     * historical network statistics belonging to other UIDs, use
+     * {@link NetworkStatsManager}.
+     *
+     * @see android.os.Process#myUid()
+     * @see android.content.pm.ApplicationInfo#uid
+     */
+    public static long getUidRxPackets(int uid) {
+        try {
+            return getStatsService().getUidStats(uid, TYPE_RX_PACKETS);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidTxBytes(int)
+     */
+    @Deprecated
+    public static long getUidTcpTxBytes(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidRxBytes(int)
+     */
+    @Deprecated
+    public static long getUidTcpRxBytes(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidTxBytes(int)
+     */
+    @Deprecated
+    public static long getUidUdpTxBytes(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidRxBytes(int)
+     */
+    @Deprecated
+    public static long getUidUdpRxBytes(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidTxPackets(int)
+     */
+    @Deprecated
+    public static long getUidTcpTxSegments(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidRxPackets(int)
+     */
+    @Deprecated
+    public static long getUidTcpRxSegments(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidTxPackets(int)
+     */
+    @Deprecated
+    public static long getUidUdpTxPackets(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * @deprecated Starting in {@link android.os.Build.VERSION_CODES#JELLY_BEAN_MR2},
+     *             transport layer statistics are no longer available, and will
+     *             always return {@link #UNSUPPORTED}.
+     * @see #getUidRxPackets(int)
+     */
+    @Deprecated
+    public static long getUidUdpRxPackets(int uid) {
+        return UNSUPPORTED;
+    }
+
+    /**
+     * Return detailed {@link NetworkStats} for the current UID. Requires no
+     * special permission.
+     */
+    private static NetworkStats getDataLayerSnapshotForUid(Context context) {
+        // TODO: take snapshot locally, since proc file is now visible
+        final int uid = android.os.Process.myUid();
+        try {
+            return getStatsService().getDataLayerSnapshotForUid(uid);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Return set of any ifaces associated with mobile networks since boot.
+     * Interfaces are never removed from this list, so counters should always be
+     * monotonic.
+     */
+    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 130143562)
+    private static String[] getMobileIfaces() {
+        try {
+            return getStatsService().getMobileIfaces();
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    // NOTE: keep these in sync with {@code com_android_server_net_NetworkStatsService.cpp}.
+    /** {@hide} */
+    public static final int TYPE_RX_BYTES = 0;
+    /** {@hide} */
+    public static final int TYPE_RX_PACKETS = 1;
+    /** {@hide} */
+    public static final int TYPE_TX_BYTES = 2;
+    /** {@hide} */
+    public static final int TYPE_TX_PACKETS = 3;
+    /** {@hide} */
+    public static final int TYPE_TCP_RX_PACKETS = 4;
+    /** {@hide} */
+    public static final int TYPE_TCP_TX_PACKETS = 5;
+}
diff --git a/framework-t/src/android/net/UnderlyingNetworkInfo.aidl b/framework-t/src/android/net/UnderlyingNetworkInfo.aidl
new file mode 100644
index 0000000..a56f2f4
--- /dev/null
+++ b/framework-t/src/android/net/UnderlyingNetworkInfo.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2015 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;
+
+parcelable UnderlyingNetworkInfo;
diff --git a/framework-t/src/android/net/UnderlyingNetworkInfo.java b/framework-t/src/android/net/UnderlyingNetworkInfo.java
new file mode 100644
index 0000000..7ab53b1
--- /dev/null
+++ b/framework-t/src/android/net/UnderlyingNetworkInfo.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2015 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.annotation.SystemApi.Client.MODULE_LIBRARIES;
+
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A lightweight container used to carry information on the networks that underly a given
+ * virtual network.
+ *
+ * @hide
+ */
+@SystemApi(client = MODULE_LIBRARIES)
+public final class UnderlyingNetworkInfo implements Parcelable {
+    /** The owner of this network. */
+    private final int mOwnerUid;
+
+    /** The interface name of this network. */
+    @NonNull
+    private final String mIface;
+
+    /** The names of the interfaces underlying this network. */
+    @NonNull
+    private final List<String> mUnderlyingIfaces;
+
+    public UnderlyingNetworkInfo(int ownerUid, @NonNull String iface,
+            @NonNull List<String> underlyingIfaces) {
+        Objects.requireNonNull(iface);
+        Objects.requireNonNull(underlyingIfaces);
+        mOwnerUid = ownerUid;
+        mIface = iface;
+        mUnderlyingIfaces = Collections.unmodifiableList(new ArrayList<>(underlyingIfaces));
+    }
+
+    private UnderlyingNetworkInfo(@NonNull Parcel in) {
+        mOwnerUid = in.readInt();
+        mIface = in.readString();
+        List<String> underlyingIfaces = new ArrayList<>();
+        in.readList(underlyingIfaces, null /*classLoader*/, java.lang.String.class);
+        mUnderlyingIfaces = Collections.unmodifiableList(underlyingIfaces);
+    }
+
+    /** Get the owner of this network. */
+    public int getOwnerUid() {
+        return mOwnerUid;
+    }
+
+    /** Get the interface name of this network. */
+    @NonNull
+    public String getInterface() {
+        return mIface;
+    }
+
+    /** Get the names of the interfaces underlying this network. */
+    @NonNull
+    public List<String> getUnderlyingInterfaces() {
+        return mUnderlyingIfaces;
+    }
+
+    @Override
+    public String toString() {
+        return "UnderlyingNetworkInfo{"
+                + "ownerUid=" + mOwnerUid
+                + ", iface='" + mIface + '\''
+                + ", underlyingIfaces='" + mUnderlyingIfaces.toString() + '\''
+                + '}';
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mOwnerUid);
+        dest.writeString(mIface);
+        dest.writeList(mUnderlyingIfaces);
+    }
+
+    @NonNull
+    public static final Parcelable.Creator<UnderlyingNetworkInfo> CREATOR =
+            new Parcelable.Creator<UnderlyingNetworkInfo>() {
+        @NonNull
+        @Override
+        public UnderlyingNetworkInfo createFromParcel(@NonNull Parcel in) {
+            return new UnderlyingNetworkInfo(in);
+        }
+
+        @NonNull
+        @Override
+        public UnderlyingNetworkInfo[] newArray(int size) {
+            return new UnderlyingNetworkInfo[size];
+        }
+    };
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof UnderlyingNetworkInfo)) return false;
+        final UnderlyingNetworkInfo that = (UnderlyingNetworkInfo) o;
+        return mOwnerUid == that.getOwnerUid()
+                && Objects.equals(mIface, that.getInterface())
+                && Objects.equals(mUnderlyingIfaces, that.getUnderlyingInterfaces());
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mOwnerUid, mIface, mUnderlyingIfaces);
+    }
+}
diff --git a/framework-t/src/android/net/netstats/IUsageCallback.aidl b/framework-t/src/android/net/netstats/IUsageCallback.aidl
new file mode 100644
index 0000000..4e8a5b2
--- /dev/null
+++ b/framework-t/src/android/net/netstats/IUsageCallback.aidl
@@ -0,0 +1,29 @@
+/*
+ * 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.net.netstats;
+
+import android.net.DataUsageRequest;
+
+/**
+ * Interface for NetworkStatsService to notify events to the callers of registerUsageCallback.
+ *
+ * @hide
+ */
+oneway interface IUsageCallback {
+    void onThresholdReached(in DataUsageRequest request);
+    void onCallbackReleased(in DataUsageRequest request);
+}
diff --git a/framework-t/src/android/net/netstats/provider/INetworkStatsProvider.aidl b/framework-t/src/android/net/netstats/provider/INetworkStatsProvider.aidl
new file mode 100644
index 0000000..74c3ba4
--- /dev/null
+++ b/framework-t/src/android/net/netstats/provider/INetworkStatsProvider.aidl
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2020 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.netstats.provider;
+
+/**
+ * Interface for NetworkStatsService to query network statistics and set data limits.
+ *
+ * @hide
+ */
+oneway interface INetworkStatsProvider {
+    void onRequestStatsUpdate(int token);
+    void onSetAlert(long quotaBytes);
+    void onSetWarningAndLimit(String iface, long warningBytes, long limitBytes);
+}
diff --git a/framework-t/src/android/net/netstats/provider/INetworkStatsProviderCallback.aidl b/framework-t/src/android/net/netstats/provider/INetworkStatsProviderCallback.aidl
new file mode 100644
index 0000000..01ff02d
--- /dev/null
+++ b/framework-t/src/android/net/netstats/provider/INetworkStatsProviderCallback.aidl
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2020 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.netstats.provider;
+
+import android.net.NetworkStats;
+
+/**
+ * Interface for implementor of {@link INetworkStatsProviderCallback} to push events
+ * such as network statistics update or notify limit reached.
+ * @hide
+ */
+oneway interface INetworkStatsProviderCallback {
+    void notifyStatsUpdated(int token, in NetworkStats ifaceStats, in NetworkStats uidStats);
+    void notifyAlertReached();
+    void notifyWarningReached();
+    void notifyLimitReached();
+    void unregister();
+}
diff --git a/framework-t/src/android/net/netstats/provider/NetworkStatsProvider.java b/framework-t/src/android/net/netstats/provider/NetworkStatsProvider.java
new file mode 100644
index 0000000..d37a53d
--- /dev/null
+++ b/framework-t/src/android/net/netstats/provider/NetworkStatsProvider.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2020 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.netstats.provider;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.net.NetworkStats;
+import android.os.RemoteException;
+
+/**
+ * A base class that allows external modules to implement a custom network statistics provider.
+ * @hide
+ */
+@SystemApi
+public abstract class NetworkStatsProvider {
+    /**
+     * A value used by {@link #onSetLimit}, {@link #onSetAlert} and {@link #onSetWarningAndLimit}
+     * indicates there is no limit.
+     */
+    public static final int QUOTA_UNLIMITED = -1;
+
+    @NonNull private final INetworkStatsProvider mProviderBinder =
+            new INetworkStatsProvider.Stub() {
+
+        @Override
+        public void onRequestStatsUpdate(int token) {
+            NetworkStatsProvider.this.onRequestStatsUpdate(token);
+        }
+
+        @Override
+        public void onSetAlert(long quotaBytes) {
+            NetworkStatsProvider.this.onSetAlert(quotaBytes);
+        }
+
+        @Override
+        public void onSetWarningAndLimit(String iface, long warningBytes, long limitBytes) {
+            NetworkStatsProvider.this.onSetWarningAndLimit(iface, warningBytes, limitBytes);
+        }
+    };
+
+    // The binder given by the service when successfully registering. Only null before registering,
+    // never null once non-null.
+    @Nullable
+    private INetworkStatsProviderCallback mProviderCbBinder;
+
+    /**
+     * Return the binder invoked by the service and redirect function calls to the overridden
+     * methods.
+     * @hide
+     */
+    @NonNull
+    public INetworkStatsProvider getProviderBinder() {
+        return mProviderBinder;
+    }
+
+    /**
+     * Store the binder that was returned by the service when successfully registering. Note that
+     * the provider cannot be re-registered. Hence this method can only be called once per provider.
+     *
+     * @hide
+     */
+    public void setProviderCallbackBinder(@NonNull INetworkStatsProviderCallback binder) {
+        if (mProviderCbBinder != null) {
+            throw new IllegalArgumentException("provider is already registered");
+        }
+        mProviderCbBinder = binder;
+    }
+
+    /**
+     * Get the binder that was returned by the service when successfully registering. Or null if the
+     * provider was never registered.
+     *
+     * @hide
+     */
+    @Nullable
+    public INetworkStatsProviderCallback getProviderCallbackBinder() {
+        return mProviderCbBinder;
+    }
+
+    /**
+     * Get the binder that was returned by the service when successfully registering. Throw an
+     * {@link IllegalStateException} if the provider is not registered.
+     *
+     * @hide
+     */
+    @NonNull
+    public INetworkStatsProviderCallback getProviderCallbackBinderOrThrow() {
+        if (mProviderCbBinder == null) {
+            throw new IllegalStateException("the provider is not registered");
+        }
+        return mProviderCbBinder;
+    }
+
+    /**
+     * Notify the system of new network statistics.
+     *
+     * Send the network statistics recorded since the last call to {@link #notifyStatsUpdated}. Must
+     * be called as soon as possible after {@link NetworkStatsProvider#onRequestStatsUpdate(int)}
+     * being called. Responding later increases the probability stats will be dropped. The
+     * provider can also call this whenever it wants to reports new stats for any reason.
+     * Note that the system will not necessarily immediately propagate the statistics to
+     * reflect the update.
+     *
+     * @param token the token under which these stats were gathered. Providers can call this method
+     *              with the current token as often as they want, until the token changes.
+     *              {@see NetworkStatsProvider#onRequestStatsUpdate()}
+     * @param ifaceStats the {@link NetworkStats} per interface to be reported.
+     *                   The provider should not include any traffic that is already counted by
+     *                   kernel interface counters.
+     * @param uidStats the same stats as above, but counts {@link NetworkStats}
+     *                 per uid.
+     */
+    public void notifyStatsUpdated(int token, @NonNull NetworkStats ifaceStats,
+            @NonNull NetworkStats uidStats) {
+        try {
+            getProviderCallbackBinderOrThrow().notifyStatsUpdated(token, ifaceStats, uidStats);
+        } catch (RemoteException e) {
+            e.rethrowAsRuntimeException();
+        }
+    }
+
+    /**
+     * Notify system that the quota set by {@code onSetAlert} has been reached.
+     */
+    public void notifyAlertReached() {
+        try {
+            getProviderCallbackBinderOrThrow().notifyAlertReached();
+        } catch (RemoteException e) {
+            e.rethrowAsRuntimeException();
+        }
+    }
+
+    /**
+     * Notify system that the warning set by {@link #onSetWarningAndLimit} has been reached.
+     */
+    public void notifyWarningReached() {
+        try {
+            // Reuse the code path to notify warning reached with limit reached
+            // since framework handles them in the same way.
+            getProviderCallbackBinderOrThrow().notifyWarningReached();
+        } catch (RemoteException e) {
+            e.rethrowAsRuntimeException();
+        }
+    }
+
+    /**
+     * Notify system that the limit set by {@link #onSetLimit} or limit set by
+     * {@link #onSetWarningAndLimit} has been reached.
+     */
+    public void notifyLimitReached() {
+        try {
+            getProviderCallbackBinderOrThrow().notifyLimitReached();
+        } catch (RemoteException e) {
+            e.rethrowAsRuntimeException();
+        }
+    }
+
+    /**
+     * Called by {@code NetworkStatsService} when it requires to know updated stats.
+     * The provider MUST respond by calling {@link #notifyStatsUpdated} as soon as possible.
+     * Responding later increases the probability stats will be dropped. Memory allowing, the
+     * system will try to take stats into account up to one minute after calling
+     * {@link #onRequestStatsUpdate}.
+     *
+     * @param token a positive number identifying the new state of the system under which
+     *              {@link NetworkStats} have to be gathered from now on. When this is called,
+     *              custom implementations of providers MUST tally and report the latest stats with
+     *              the previous token, under which stats were being gathered so far.
+     */
+    public abstract void onRequestStatsUpdate(int token);
+
+    /**
+     * Called by {@code NetworkStatsService} when setting the interface quota for the specified
+     * upstream interface. When this is called, the custom implementation should block all egress
+     * packets on the {@code iface} associated with the provider when {@code quotaBytes} bytes have
+     * been reached, and MUST respond to it by calling
+     * {@link NetworkStatsProvider#notifyLimitReached()}.
+     *
+     * @param iface the interface requiring the operation.
+     * @param quotaBytes the quota defined as the number of bytes, starting from zero and counting
+     *                   from now. A value of {@link #QUOTA_UNLIMITED} indicates there is no limit.
+     */
+    public abstract void onSetLimit(@NonNull String iface, long quotaBytes);
+
+    /**
+     * Called by {@code NetworkStatsService} when setting the interface quotas for the specified
+     * upstream interface. If a provider implements {@link #onSetWarningAndLimit}, the system
+     * will not call {@link #onSetLimit}. When this method is called, the implementation
+     * should behave as follows:
+     *   1. If {@code warningBytes} is reached on {@code iface}, block all further traffic on
+     *      {@code iface} and call {@link NetworkStatsProvider@notifyWarningReached()}.
+     *   2. If {@code limitBytes} is reached on {@code iface}, block all further traffic on
+     *   {@code iface} and call {@link NetworkStatsProvider#notifyLimitReached()}.
+     *
+     * @param iface the interface requiring the operation.
+     * @param warningBytes the warning defined as the number of bytes, starting from zero and
+     *                     counting from now. A value of {@link #QUOTA_UNLIMITED} indicates
+     *                     there is no warning.
+     * @param limitBytes the limit defined as the number of bytes, starting from zero and counting
+     *                   from now. A value of {@link #QUOTA_UNLIMITED} indicates there is no limit.
+     */
+    public void onSetWarningAndLimit(@NonNull String iface, long warningBytes, long limitBytes) {
+        // Backward compatibility for those who didn't override this function.
+        onSetLimit(iface, limitBytes);
+    }
+
+    /**
+     * Called by {@code NetworkStatsService} when setting the alert bytes. Custom implementations
+     * MUST call {@link NetworkStatsProvider#notifyAlertReached()} when {@code quotaBytes} bytes
+     * have been reached. Unlike {@link #onSetLimit(String, long)}, the custom implementation should
+     * not block all egress packets.
+     *
+     * @param quotaBytes the quota defined as the number of bytes, starting from zero and counting
+     *                   from now. A value of {@link #QUOTA_UNLIMITED} indicates there is no alert.
+     */
+    public abstract void onSetAlert(long quotaBytes);
+}
diff --git a/framework-t/src/android/net/nsd/INsdManager.aidl b/framework-t/src/android/net/nsd/INsdManager.aidl
new file mode 100644
index 0000000..89e9cdb
--- /dev/null
+++ b/framework-t/src/android/net/nsd/INsdManager.aidl
@@ -0,0 +1,30 @@
+/**
+ * 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 android.net.nsd;
+
+import android.net.nsd.INsdManagerCallback;
+import android.net.nsd.INsdServiceConnector;
+import android.os.Messenger;
+
+/**
+ * Interface that NsdService implements to connect NsdManager clients.
+ *
+ * {@hide}
+ */
+interface INsdManager {
+    INsdServiceConnector connect(INsdManagerCallback cb);
+}
diff --git a/framework-t/src/android/net/nsd/INsdManagerCallback.aidl b/framework-t/src/android/net/nsd/INsdManagerCallback.aidl
new file mode 100644
index 0000000..1a262ec
--- /dev/null
+++ b/framework-t/src/android/net/nsd/INsdManagerCallback.aidl
@@ -0,0 +1,39 @@
+/**
+ * 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 android.net.nsd;
+
+import android.os.Messenger;
+import android.net.nsd.NsdServiceInfo;
+
+/**
+ * Callbacks from NsdService to NsdManager
+ * @hide
+ */
+oneway interface INsdManagerCallback {
+    void onDiscoverServicesStarted(int listenerKey, in NsdServiceInfo info);
+    void onDiscoverServicesFailed(int listenerKey, int error);
+    void onServiceFound(int listenerKey, in NsdServiceInfo info);
+    void onServiceLost(int listenerKey, in NsdServiceInfo info);
+    void onStopDiscoveryFailed(int listenerKey, int error);
+    void onStopDiscoverySucceeded(int listenerKey);
+    void onRegisterServiceFailed(int listenerKey, int error);
+    void onRegisterServiceSucceeded(int listenerKey, in NsdServiceInfo info);
+    void onUnregisterServiceFailed(int listenerKey, int error);
+    void onUnregisterServiceSucceeded(int listenerKey);
+    void onResolveServiceFailed(int listenerKey, int error);
+    void onResolveServiceSucceeded(int listenerKey, in NsdServiceInfo info);
+}
diff --git a/framework-t/src/android/net/nsd/INsdServiceConnector.aidl b/framework-t/src/android/net/nsd/INsdServiceConnector.aidl
new file mode 100644
index 0000000..b06ae55
--- /dev/null
+++ b/framework-t/src/android/net/nsd/INsdServiceConnector.aidl
@@ -0,0 +1,35 @@
+/**
+ * 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 android.net.nsd;
+
+import android.net.nsd.INsdManagerCallback;
+import android.net.nsd.NsdServiceInfo;
+import android.os.Messenger;
+
+/**
+ * Interface that NsdService implements for each NsdManager client.
+ *
+ * {@hide}
+ */
+interface INsdServiceConnector {
+    void registerService(int listenerKey, in NsdServiceInfo serviceInfo);
+    void unregisterService(int listenerKey);
+    void discoverServices(int listenerKey, in NsdServiceInfo serviceInfo);
+    void stopDiscovery(int listenerKey);
+    void resolveService(int listenerKey, in NsdServiceInfo serviceInfo);
+    void startDaemon();
+}
\ No newline at end of file
diff --git a/framework-t/src/android/net/nsd/NsdManager.java b/framework-t/src/android/net/nsd/NsdManager.java
new file mode 100644
index 0000000..209f372
--- /dev/null
+++ b/framework-t/src/android/net/nsd/NsdManager.java
@@ -0,0 +1,1083 @@
+/*
+ * 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 android.net.nsd;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SdkConstant;
+import android.annotation.SdkConstant.SdkConstantType;
+import android.annotation.SystemService;
+import android.app.compat.CompatChanges;
+import android.compat.annotation.ChangeId;
+import android.compat.annotation.EnabledSince;
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.ConnectivityManager.NetworkCallback;
+import android.net.Network;
+import android.net.NetworkRequest;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.RemoteException;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Objects;
+import java.util.concurrent.Executor;
+
+/**
+ * The Network Service Discovery Manager class provides the API to discover services
+ * on a network. As an example, if device A and device B are connected over a Wi-Fi
+ * network, a game registered on device A can be discovered by a game on device
+ * B. Another example use case is an application discovering printers on the network.
+ *
+ * <p> The API currently supports DNS based service discovery and discovery is currently
+ * limited to a local network over Multicast DNS. DNS service discovery is described at
+ * http://files.dns-sd.org/draft-cheshire-dnsext-dns-sd.txt
+ *
+ * <p> The API is asynchronous, and responses to requests from an application are on listener
+ * callbacks on a separate internal thread.
+ *
+ * <p> There are three main operations the API supports - registration, discovery and resolution.
+ * <pre>
+ *                          Application start
+ *                                 |
+ *                                 |
+ *                                 |                  onServiceRegistered()
+ *                     Register any local services  /
+ *                      to be advertised with       \
+ *                       registerService()            onRegistrationFailed()
+ *                                 |
+ *                                 |
+ *                          discoverServices()
+ *                                 |
+ *                      Maintain a list to track
+ *                        discovered services
+ *                                 |
+ *                                 |--------->
+ *                                 |          |
+ *                                 |      onServiceFound()
+ *                                 |          |
+ *                                 |     add service to list
+ *                                 |          |
+ *                                 |<----------
+ *                                 |
+ *                                 |--------->
+ *                                 |          |
+ *                                 |      onServiceLost()
+ *                                 |          |
+ *                                 |   remove service from list
+ *                                 |          |
+ *                                 |<----------
+ *                                 |
+ *                                 |
+ *                                 | Connect to a service
+ *                                 | from list ?
+ *                                 |
+ *                          resolveService()
+ *                                 |
+ *                         onServiceResolved()
+ *                                 |
+ *                     Establish connection to service
+ *                     with the host and port information
+ *
+ * </pre>
+ * An application that needs to advertise itself over a network for other applications to
+ * discover it can do so with a call to {@link #registerService}. If Example is a http based
+ * application that can provide HTML data to peer services, it can register a name "Example"
+ * with service type "_http._tcp". A successful registration is notified with a callback to
+ * {@link RegistrationListener#onServiceRegistered} and a failure to register is notified
+ * over {@link RegistrationListener#onRegistrationFailed}
+ *
+ * <p> A peer application looking for http services can initiate a discovery for "_http._tcp"
+ * with a call to {@link #discoverServices}. A service found is notified with a callback
+ * to {@link DiscoveryListener#onServiceFound} and a service lost is notified on
+ * {@link DiscoveryListener#onServiceLost}.
+ *
+ * <p> Once the peer application discovers the "Example" http service, and either needs to read the
+ * attributes of the service or wants to receive data from the "Example" application, it can
+ * initiate a resolve with {@link #resolveService} to resolve the attributes, host, and port
+ * details. A successful resolve is notified on {@link ResolveListener#onServiceResolved} and a
+ * failure is notified on {@link ResolveListener#onResolveFailed}.
+ *
+ * Applications can reserve for a service type at
+ * http://www.iana.org/form/ports-service. Existing services can be found at
+ * http://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xml
+ *
+ * {@see NsdServiceInfo}
+ */
+@SystemService(Context.NSD_SERVICE)
+public final class NsdManager {
+    private static final String TAG = NsdManager.class.getSimpleName();
+    private static final boolean DBG = false;
+
+    /**
+     * When enabled, apps targeting < Android 12 are considered legacy for
+     * the NSD native daemon.
+     * The platform will only keep the daemon running as long as there are
+     * any legacy apps connected.
+     *
+     * After Android 12, directly communicate with native daemon might not
+     * work since the native damon won't always stay alive.
+     * Use the NSD APIs from NsdManager as the replacement is recommended.
+     * An another alternative could be bundling your own mdns solutions instead of
+     * depending on the system mdns native daemon.
+     *
+     * @hide
+     */
+    @ChangeId
+    @EnabledSince(targetSdkVersion = android.os.Build.VERSION_CODES.S)
+    public static final long RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS = 191844585L;
+
+    /**
+     * Broadcast intent action to indicate whether network service discovery is
+     * enabled or disabled. An extra {@link #EXTRA_NSD_STATE} provides the state
+     * information as int.
+     *
+     * @see #EXTRA_NSD_STATE
+     */
+    @SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
+    public static final String ACTION_NSD_STATE_CHANGED = "android.net.nsd.STATE_CHANGED";
+
+    /**
+     * The lookup key for an int that indicates whether network service discovery is enabled
+     * or disabled. Retrieve it with {@link android.content.Intent#getIntExtra(String,int)}.
+     *
+     * @see #NSD_STATE_DISABLED
+     * @see #NSD_STATE_ENABLED
+     */
+    public static final String EXTRA_NSD_STATE = "nsd_state";
+
+    /**
+     * Network service discovery is disabled
+     *
+     * @see #ACTION_NSD_STATE_CHANGED
+     */
+    public static final int NSD_STATE_DISABLED = 1;
+
+    /**
+     * Network service discovery is enabled
+     *
+     * @see #ACTION_NSD_STATE_CHANGED
+     */
+    public static final int NSD_STATE_ENABLED = 2;
+
+    /** @hide */
+    public static final int DISCOVER_SERVICES                       = 1;
+    /** @hide */
+    public static final int DISCOVER_SERVICES_STARTED               = 2;
+    /** @hide */
+    public static final int DISCOVER_SERVICES_FAILED                = 3;
+    /** @hide */
+    public static final int SERVICE_FOUND                           = 4;
+    /** @hide */
+    public static final int SERVICE_LOST                            = 5;
+
+    /** @hide */
+    public static final int STOP_DISCOVERY                          = 6;
+    /** @hide */
+    public static final int STOP_DISCOVERY_FAILED                   = 7;
+    /** @hide */
+    public static final int STOP_DISCOVERY_SUCCEEDED                = 8;
+
+    /** @hide */
+    public static final int REGISTER_SERVICE                        = 9;
+    /** @hide */
+    public static final int REGISTER_SERVICE_FAILED                 = 10;
+    /** @hide */
+    public static final int REGISTER_SERVICE_SUCCEEDED              = 11;
+
+    /** @hide */
+    public static final int UNREGISTER_SERVICE                      = 12;
+    /** @hide */
+    public static final int UNREGISTER_SERVICE_FAILED               = 13;
+    /** @hide */
+    public static final int UNREGISTER_SERVICE_SUCCEEDED            = 14;
+
+    /** @hide */
+    public static final int RESOLVE_SERVICE                         = 15;
+    /** @hide */
+    public static final int RESOLVE_SERVICE_FAILED                  = 16;
+    /** @hide */
+    public static final int RESOLVE_SERVICE_SUCCEEDED               = 17;
+
+    /** @hide */
+    public static final int DAEMON_CLEANUP                          = 18;
+
+    /** @hide */
+    public static final int DAEMON_STARTUP                          = 19;
+
+    /** @hide */
+    public static final int ENABLE                                  = 20;
+    /** @hide */
+    public static final int DISABLE                                 = 21;
+
+    /** @hide */
+    public static final int NATIVE_DAEMON_EVENT                     = 22;
+
+    /** @hide */
+    public static final int REGISTER_CLIENT                         = 23;
+    /** @hide */
+    public static final int UNREGISTER_CLIENT                       = 24;
+
+    /** Dns based service discovery protocol */
+    public static final int PROTOCOL_DNS_SD = 0x0001;
+
+    private static final SparseArray<String> EVENT_NAMES = new SparseArray<>();
+    static {
+        EVENT_NAMES.put(DISCOVER_SERVICES, "DISCOVER_SERVICES");
+        EVENT_NAMES.put(DISCOVER_SERVICES_STARTED, "DISCOVER_SERVICES_STARTED");
+        EVENT_NAMES.put(DISCOVER_SERVICES_FAILED, "DISCOVER_SERVICES_FAILED");
+        EVENT_NAMES.put(SERVICE_FOUND, "SERVICE_FOUND");
+        EVENT_NAMES.put(SERVICE_LOST, "SERVICE_LOST");
+        EVENT_NAMES.put(STOP_DISCOVERY, "STOP_DISCOVERY");
+        EVENT_NAMES.put(STOP_DISCOVERY_FAILED, "STOP_DISCOVERY_FAILED");
+        EVENT_NAMES.put(STOP_DISCOVERY_SUCCEEDED, "STOP_DISCOVERY_SUCCEEDED");
+        EVENT_NAMES.put(REGISTER_SERVICE, "REGISTER_SERVICE");
+        EVENT_NAMES.put(REGISTER_SERVICE_FAILED, "REGISTER_SERVICE_FAILED");
+        EVENT_NAMES.put(REGISTER_SERVICE_SUCCEEDED, "REGISTER_SERVICE_SUCCEEDED");
+        EVENT_NAMES.put(UNREGISTER_SERVICE, "UNREGISTER_SERVICE");
+        EVENT_NAMES.put(UNREGISTER_SERVICE_FAILED, "UNREGISTER_SERVICE_FAILED");
+        EVENT_NAMES.put(UNREGISTER_SERVICE_SUCCEEDED, "UNREGISTER_SERVICE_SUCCEEDED");
+        EVENT_NAMES.put(RESOLVE_SERVICE, "RESOLVE_SERVICE");
+        EVENT_NAMES.put(RESOLVE_SERVICE_FAILED, "RESOLVE_SERVICE_FAILED");
+        EVENT_NAMES.put(RESOLVE_SERVICE_SUCCEEDED, "RESOLVE_SERVICE_SUCCEEDED");
+        EVENT_NAMES.put(DAEMON_CLEANUP, "DAEMON_CLEANUP");
+        EVENT_NAMES.put(DAEMON_STARTUP, "DAEMON_STARTUP");
+        EVENT_NAMES.put(ENABLE, "ENABLE");
+        EVENT_NAMES.put(DISABLE, "DISABLE");
+        EVENT_NAMES.put(NATIVE_DAEMON_EVENT, "NATIVE_DAEMON_EVENT");
+    }
+
+    /** @hide */
+    public static String nameOf(int event) {
+        String name = EVENT_NAMES.get(event);
+        if (name == null) {
+            return Integer.toString(event);
+        }
+        return name;
+    }
+
+    private static final int FIRST_LISTENER_KEY = 1;
+
+    private final INsdServiceConnector mService;
+    private final Context mContext;
+
+    private int mListenerKey = FIRST_LISTENER_KEY;
+    @GuardedBy("mMapLock")
+    private final SparseArray mListenerMap = new SparseArray();
+    @GuardedBy("mMapLock")
+    private final SparseArray<NsdServiceInfo> mServiceMap = new SparseArray<>();
+    @GuardedBy("mMapLock")
+    private final SparseArray<Executor> mExecutorMap = new SparseArray<>();
+    private final Object mMapLock = new Object();
+    // Map of listener key sent by client -> per-network discovery tracker
+    @GuardedBy("mPerNetworkDiscoveryMap")
+    private final ArrayMap<Integer, PerNetworkDiscoveryTracker>
+            mPerNetworkDiscoveryMap = new ArrayMap<>();
+
+    private final ServiceHandler mHandler;
+
+    private class PerNetworkDiscoveryTracker {
+        final String mServiceType;
+        final int mProtocolType;
+        final DiscoveryListener mBaseListener;
+        final Executor mBaseExecutor;
+        final ArrayMap<Network, DelegatingDiscoveryListener> mPerNetworkListeners =
+                new ArrayMap<>();
+
+        final NetworkCallback mNetworkCb = new NetworkCallback() {
+            @Override
+            public void onAvailable(@NonNull Network network) {
+                final DelegatingDiscoveryListener wrappedListener = new DelegatingDiscoveryListener(
+                        network, mBaseListener);
+                mPerNetworkListeners.put(network, wrappedListener);
+                discoverServices(mServiceType, mProtocolType, network, mBaseExecutor,
+                        wrappedListener);
+            }
+
+            @Override
+            public void onLost(@NonNull Network network) {
+                final DelegatingDiscoveryListener listener = mPerNetworkListeners.get(network);
+                if (listener == null) return;
+                listener.notifyAllServicesLost();
+                // Listener will be removed from map in discovery stopped callback
+                stopServiceDiscovery(listener);
+            }
+        };
+
+        // Accessed from mHandler
+        private boolean mStopRequested;
+
+        public void start(@NonNull NetworkRequest request) {
+            final ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+            cm.registerNetworkCallback(request, mNetworkCb, mHandler);
+            mHandler.post(() -> mBaseListener.onDiscoveryStarted(mServiceType));
+        }
+
+        /**
+         * Stop discovery on all networks tracked by this class.
+         *
+         * This will request all underlying listeners to stop, and the last one to stop will call
+         * onDiscoveryStopped or onStopDiscoveryFailed.
+         *
+         * Must be called on the handler thread.
+         */
+        public void requestStop() {
+            mHandler.post(() -> {
+                mStopRequested = true;
+                final ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+                cm.unregisterNetworkCallback(mNetworkCb);
+                if (mPerNetworkListeners.size() == 0) {
+                    mBaseListener.onDiscoveryStopped(mServiceType);
+                    return;
+                }
+                for (int i = 0; i < mPerNetworkListeners.size(); i++) {
+                    final DelegatingDiscoveryListener listener = mPerNetworkListeners.valueAt(i);
+                    stopServiceDiscovery(listener);
+                }
+            });
+        }
+
+        private PerNetworkDiscoveryTracker(String serviceType, int protocolType,
+                Executor baseExecutor, DiscoveryListener baseListener) {
+            mServiceType = serviceType;
+            mProtocolType = protocolType;
+            mBaseExecutor = baseExecutor;
+            mBaseListener = baseListener;
+        }
+
+        /**
+         * Subset of NsdServiceInfo that is tracked to generate service lost notifications when a
+         * network is lost.
+         *
+         * Service lost notifications only contain service name, type and network, so only track
+         * that information (Network is known from the listener). This also implements
+         * equals/hashCode for usage in maps.
+         */
+        private class TrackedNsdInfo {
+            private final String mServiceName;
+            private final String mServiceType;
+            TrackedNsdInfo(NsdServiceInfo info) {
+                mServiceName = info.getServiceName();
+                mServiceType = info.getServiceType();
+            }
+
+            @Override
+            public int hashCode() {
+                return Objects.hash(mServiceName, mServiceType);
+            }
+
+            @Override
+            public boolean equals(Object obj) {
+                if (!(obj instanceof TrackedNsdInfo)) return false;
+                final TrackedNsdInfo other = (TrackedNsdInfo) obj;
+                return Objects.equals(mServiceName, other.mServiceName)
+                        && Objects.equals(mServiceType, other.mServiceType);
+            }
+        }
+
+        private class DelegatingDiscoveryListener implements DiscoveryListener {
+            private final Network mNetwork;
+            private final DiscoveryListener mWrapped;
+            private final ArraySet<TrackedNsdInfo> mFoundInfo = new ArraySet<>();
+
+            private DelegatingDiscoveryListener(Network network, DiscoveryListener listener) {
+                mNetwork = network;
+                mWrapped = listener;
+            }
+
+            void notifyAllServicesLost() {
+                for (int i = 0; i < mFoundInfo.size(); i++) {
+                    final TrackedNsdInfo trackedInfo = mFoundInfo.valueAt(i);
+                    final NsdServiceInfo serviceInfo = new NsdServiceInfo(
+                            trackedInfo.mServiceName, trackedInfo.mServiceType);
+                    serviceInfo.setNetwork(mNetwork);
+                    mWrapped.onServiceLost(serviceInfo);
+                }
+            }
+
+            @Override
+            public void onStartDiscoveryFailed(String serviceType, int errorCode) {
+                // The delegated listener is used when NsdManager takes care of starting/stopping
+                // discovery on multiple networks. Failure to start on one network is not a global
+                // failure to be reported up, as other networks may succeed: just log.
+                Log.e(TAG, "Failed to start discovery for " + serviceType + " on " + mNetwork
+                        + " with code " + errorCode);
+                mPerNetworkListeners.remove(mNetwork);
+            }
+
+            @Override
+            public void onDiscoveryStarted(String serviceType) {
+                // Wrapped listener was called upon registration, it is not called for discovery
+                // on each network
+            }
+
+            @Override
+            public void onStopDiscoveryFailed(String serviceType, int errorCode) {
+                Log.e(TAG, "Failed to stop discovery for " + serviceType + " on " + mNetwork
+                        + " with code " + errorCode);
+                mPerNetworkListeners.remove(mNetwork);
+                if (mStopRequested && mPerNetworkListeners.size() == 0) {
+                    // Do not report onStopDiscoveryFailed when some underlying listeners failed:
+                    // this does not mean that all listeners did, and onStopDiscoveryFailed is not
+                    // actionable anyway. Just report that discovery stopped.
+                    mWrapped.onDiscoveryStopped(serviceType);
+                }
+            }
+
+            @Override
+            public void onDiscoveryStopped(String serviceType) {
+                mPerNetworkListeners.remove(mNetwork);
+                if (mStopRequested && mPerNetworkListeners.size() == 0) {
+                    mWrapped.onDiscoveryStopped(serviceType);
+                }
+            }
+
+            @Override
+            public void onServiceFound(NsdServiceInfo serviceInfo) {
+                mFoundInfo.add(new TrackedNsdInfo(serviceInfo));
+                mWrapped.onServiceFound(serviceInfo);
+            }
+
+            @Override
+            public void onServiceLost(NsdServiceInfo serviceInfo) {
+                mFoundInfo.remove(new TrackedNsdInfo(serviceInfo));
+                mWrapped.onServiceLost(serviceInfo);
+            }
+        }
+    }
+
+    /**
+     * Create a new Nsd instance. Applications use
+     * {@link android.content.Context#getSystemService Context.getSystemService()} to retrieve
+     * {@link android.content.Context#NSD_SERVICE Context.NSD_SERVICE}.
+     * @param service the Binder interface
+     * @hide - hide this because it takes in a parameter of type INsdManager, which
+     * is a system private class.
+     */
+    public NsdManager(Context context, INsdManager service) {
+        mContext = context;
+
+        HandlerThread t = new HandlerThread("NsdManager");
+        t.start();
+        mHandler = new ServiceHandler(t.getLooper());
+
+        try {
+            mService = service.connect(new NsdCallbackImpl(mHandler));
+        } catch (RemoteException e) {
+            throw new RuntimeException("Failed to connect to NsdService");
+        }
+
+        // Only proactively start the daemon if the target SDK < S, otherwise the internal service
+        // would automatically start/stop the native daemon as needed.
+        if (!CompatChanges.isChangeEnabled(RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS)) {
+            try {
+                mService.startDaemon();
+            } catch (RemoteException e) {
+                Log.e(TAG, "Failed to proactively start daemon");
+                // Continue: the daemon can still be started on-demand later
+            }
+        }
+    }
+
+    private static class NsdCallbackImpl extends INsdManagerCallback.Stub {
+        private final Handler mServHandler;
+
+        NsdCallbackImpl(Handler serviceHandler) {
+            mServHandler = serviceHandler;
+        }
+
+        private void sendInfo(int message, int listenerKey, NsdServiceInfo info) {
+            mServHandler.sendMessage(mServHandler.obtainMessage(message, 0, listenerKey, info));
+        }
+
+        private void sendError(int message, int listenerKey, int error) {
+            mServHandler.sendMessage(mServHandler.obtainMessage(message, error, listenerKey));
+        }
+
+        private void sendNoArg(int message, int listenerKey) {
+            mServHandler.sendMessage(mServHandler.obtainMessage(message, 0, listenerKey));
+        }
+
+        @Override
+        public void onDiscoverServicesStarted(int listenerKey, NsdServiceInfo info) {
+            sendInfo(DISCOVER_SERVICES_STARTED, listenerKey, info);
+        }
+
+        @Override
+        public void onDiscoverServicesFailed(int listenerKey, int error) {
+            sendError(DISCOVER_SERVICES_FAILED, listenerKey, error);
+        }
+
+        @Override
+        public void onServiceFound(int listenerKey, NsdServiceInfo info) {
+            sendInfo(SERVICE_FOUND, listenerKey, info);
+        }
+
+        @Override
+        public void onServiceLost(int listenerKey, NsdServiceInfo info) {
+            sendInfo(SERVICE_LOST, listenerKey, info);
+        }
+
+        @Override
+        public void onStopDiscoveryFailed(int listenerKey, int error) {
+            sendError(STOP_DISCOVERY_FAILED, listenerKey, error);
+        }
+
+        @Override
+        public void onStopDiscoverySucceeded(int listenerKey) {
+            sendNoArg(STOP_DISCOVERY_SUCCEEDED, listenerKey);
+        }
+
+        @Override
+        public void onRegisterServiceFailed(int listenerKey, int error) {
+            sendError(REGISTER_SERVICE_FAILED, listenerKey, error);
+        }
+
+        @Override
+        public void onRegisterServiceSucceeded(int listenerKey, NsdServiceInfo info) {
+            sendInfo(REGISTER_SERVICE_SUCCEEDED, listenerKey, info);
+        }
+
+        @Override
+        public void onUnregisterServiceFailed(int listenerKey, int error) {
+            sendError(UNREGISTER_SERVICE_FAILED, listenerKey, error);
+        }
+
+        @Override
+        public void onUnregisterServiceSucceeded(int listenerKey) {
+            sendNoArg(UNREGISTER_SERVICE_SUCCEEDED, listenerKey);
+        }
+
+        @Override
+        public void onResolveServiceFailed(int listenerKey, int error) {
+            sendError(RESOLVE_SERVICE_FAILED, listenerKey, error);
+        }
+
+        @Override
+        public void onResolveServiceSucceeded(int listenerKey, NsdServiceInfo info) {
+            sendInfo(RESOLVE_SERVICE_SUCCEEDED, listenerKey, info);
+        }
+    }
+
+    /**
+     * Failures are passed with {@link RegistrationListener#onRegistrationFailed},
+     * {@link RegistrationListener#onUnregistrationFailed},
+     * {@link DiscoveryListener#onStartDiscoveryFailed},
+     * {@link DiscoveryListener#onStopDiscoveryFailed} or {@link ResolveListener#onResolveFailed}.
+     *
+     * Indicates that the operation failed due to an internal error.
+     */
+    public static final int FAILURE_INTERNAL_ERROR               = 0;
+
+    /**
+     * Indicates that the operation failed because it is already active.
+     */
+    public static final int FAILURE_ALREADY_ACTIVE              = 3;
+
+    /**
+     * Indicates that the operation failed because the maximum outstanding
+     * requests from the applications have reached.
+     */
+    public static final int FAILURE_MAX_LIMIT                   = 4;
+
+    /** Interface for callback invocation for service discovery */
+    public interface DiscoveryListener {
+
+        public void onStartDiscoveryFailed(String serviceType, int errorCode);
+
+        public void onStopDiscoveryFailed(String serviceType, int errorCode);
+
+        public void onDiscoveryStarted(String serviceType);
+
+        public void onDiscoveryStopped(String serviceType);
+
+        public void onServiceFound(NsdServiceInfo serviceInfo);
+
+        public void onServiceLost(NsdServiceInfo serviceInfo);
+    }
+
+    /** Interface for callback invocation for service registration */
+    public interface RegistrationListener {
+
+        public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode);
+
+        public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode);
+
+        public void onServiceRegistered(NsdServiceInfo serviceInfo);
+
+        public void onServiceUnregistered(NsdServiceInfo serviceInfo);
+    }
+
+    /** Interface for callback invocation for service resolution */
+    public interface ResolveListener {
+
+        public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode);
+
+        public void onServiceResolved(NsdServiceInfo serviceInfo);
+    }
+
+    @VisibleForTesting
+    class ServiceHandler extends Handler {
+        ServiceHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message message) {
+            final int what = message.what;
+            final int key = message.arg2;
+            final Object listener;
+            final NsdServiceInfo ns;
+            final Executor executor;
+            synchronized (mMapLock) {
+                listener = mListenerMap.get(key);
+                ns = mServiceMap.get(key);
+                executor = mExecutorMap.get(key);
+            }
+            if (listener == null) {
+                Log.d(TAG, "Stale key " + message.arg2);
+                return;
+            }
+            if (DBG) {
+                Log.d(TAG, "received " + nameOf(what) + " for key " + key + ", service " + ns);
+            }
+            switch (what) {
+                case DISCOVER_SERVICES_STARTED:
+                    final String s = getNsdServiceInfoType((NsdServiceInfo) message.obj);
+                    executor.execute(() -> ((DiscoveryListener) listener).onDiscoveryStarted(s));
+                    break;
+                case DISCOVER_SERVICES_FAILED:
+                    removeListener(key);
+                    executor.execute(() -> ((DiscoveryListener) listener).onStartDiscoveryFailed(
+                            getNsdServiceInfoType(ns), message.arg1));
+                    break;
+                case SERVICE_FOUND:
+                    executor.execute(() -> ((DiscoveryListener) listener).onServiceFound(
+                            (NsdServiceInfo) message.obj));
+                    break;
+                case SERVICE_LOST:
+                    executor.execute(() -> ((DiscoveryListener) listener).onServiceLost(
+                            (NsdServiceInfo) message.obj));
+                    break;
+                case STOP_DISCOVERY_FAILED:
+                    // TODO: failure to stop discovery should be internal and retried internally, as
+                    // the effect for the client is indistinguishable from STOP_DISCOVERY_SUCCEEDED
+                    removeListener(key);
+                    executor.execute(() -> ((DiscoveryListener) listener).onStopDiscoveryFailed(
+                            getNsdServiceInfoType(ns), message.arg1));
+                    break;
+                case STOP_DISCOVERY_SUCCEEDED:
+                    removeListener(key);
+                    executor.execute(() -> ((DiscoveryListener) listener).onDiscoveryStopped(
+                            getNsdServiceInfoType(ns)));
+                    break;
+                case REGISTER_SERVICE_FAILED:
+                    removeListener(key);
+                    executor.execute(() -> ((RegistrationListener) listener).onRegistrationFailed(
+                            ns, message.arg1));
+                    break;
+                case REGISTER_SERVICE_SUCCEEDED:
+                    executor.execute(() -> ((RegistrationListener) listener).onServiceRegistered(
+                            (NsdServiceInfo) message.obj));
+                    break;
+                case UNREGISTER_SERVICE_FAILED:
+                    removeListener(key);
+                    executor.execute(() -> ((RegistrationListener) listener).onUnregistrationFailed(
+                            ns, message.arg1));
+                    break;
+                case UNREGISTER_SERVICE_SUCCEEDED:
+                    // TODO: do not unregister listener until service is unregistered, or provide
+                    // alternative way for unregistering ?
+                    removeListener(message.arg2);
+                    executor.execute(() -> ((RegistrationListener) listener).onServiceUnregistered(
+                            ns));
+                    break;
+                case RESOLVE_SERVICE_FAILED:
+                    removeListener(key);
+                    executor.execute(() -> ((ResolveListener) listener).onResolveFailed(
+                            ns, message.arg1));
+                    break;
+                case RESOLVE_SERVICE_SUCCEEDED:
+                    removeListener(key);
+                    executor.execute(() -> ((ResolveListener) listener).onServiceResolved(
+                            (NsdServiceInfo) message.obj));
+                    break;
+                default:
+                    Log.d(TAG, "Ignored " + message);
+                    break;
+            }
+        }
+    }
+
+    private int nextListenerKey() {
+        // Ensure mListenerKey >= FIRST_LISTENER_KEY;
+        mListenerKey = Math.max(FIRST_LISTENER_KEY, mListenerKey + 1);
+        return mListenerKey;
+    }
+
+    // Assert that the listener is not in the map, then add it and returns its key
+    private int putListener(Object listener, Executor e, NsdServiceInfo s) {
+        checkListener(listener);
+        final int key;
+        synchronized (mMapLock) {
+            int valueIndex = mListenerMap.indexOfValue(listener);
+            if (valueIndex != -1) {
+                throw new IllegalArgumentException("listener already in use");
+            }
+            key = nextListenerKey();
+            mListenerMap.put(key, listener);
+            mServiceMap.put(key, s);
+            mExecutorMap.put(key, e);
+        }
+        return key;
+    }
+
+    private void removeListener(int key) {
+        synchronized (mMapLock) {
+            mListenerMap.remove(key);
+            mServiceMap.remove(key);
+            mExecutorMap.remove(key);
+        }
+    }
+
+    private int getListenerKey(Object listener) {
+        checkListener(listener);
+        synchronized (mMapLock) {
+            int valueIndex = mListenerMap.indexOfValue(listener);
+            if (valueIndex == -1) {
+                throw new IllegalArgumentException("listener not registered");
+            }
+            return mListenerMap.keyAt(valueIndex);
+        }
+    }
+
+    private static String getNsdServiceInfoType(NsdServiceInfo s) {
+        if (s == null) return "?";
+        return s.getServiceType();
+    }
+
+    /**
+     * Register a service to be discovered by other services.
+     *
+     * <p> The function call immediately returns after sending a request to register service
+     * to the framework. The application is notified of a successful registration
+     * through the callback {@link RegistrationListener#onServiceRegistered} or a failure
+     * through {@link RegistrationListener#onRegistrationFailed}.
+     *
+     * <p> The application should call {@link #unregisterService} when the service
+     * registration is no longer required, and/or whenever the application is stopped.
+     *
+     * @param serviceInfo The service being registered
+     * @param protocolType The service discovery protocol
+     * @param listener The listener notifies of a successful registration and is used to
+     * unregister this service through a call on {@link #unregisterService}. Cannot be null.
+     * Cannot be in use for an active service registration.
+     */
+    public void registerService(NsdServiceInfo serviceInfo, int protocolType,
+            RegistrationListener listener) {
+        registerService(serviceInfo, protocolType, Runnable::run, listener);
+    }
+
+    /**
+     * Register a service to be discovered by other services.
+     *
+     * <p> The function call immediately returns after sending a request to register service
+     * to the framework. The application is notified of a successful registration
+     * through the callback {@link RegistrationListener#onServiceRegistered} or a failure
+     * through {@link RegistrationListener#onRegistrationFailed}.
+     *
+     * <p> The application should call {@link #unregisterService} when the service
+     * registration is no longer required, and/or whenever the application is stopped.
+     * @param serviceInfo The service being registered
+     * @param protocolType The service discovery protocol
+     * @param executor Executor to run listener callbacks with
+     * @param listener The listener notifies of a successful registration and is used to
+     * unregister this service through a call on {@link #unregisterService}. Cannot be null.
+     */
+    public void registerService(@NonNull NsdServiceInfo serviceInfo, int protocolType,
+            @NonNull Executor executor, @NonNull RegistrationListener listener) {
+        if (serviceInfo.getPort() <= 0) {
+            throw new IllegalArgumentException("Invalid port number");
+        }
+        checkServiceInfo(serviceInfo);
+        checkProtocol(protocolType);
+        int key = putListener(listener, executor, serviceInfo);
+        try {
+            mService.registerService(key, serviceInfo);
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Unregister a service registered through {@link #registerService}. A successful
+     * unregister is notified to the application with a call to
+     * {@link RegistrationListener#onServiceUnregistered}.
+     *
+     * @param listener This should be the listener object that was passed to
+     * {@link #registerService}. It identifies the service that should be unregistered
+     * and notifies of a successful or unsuccessful unregistration via the listener
+     * callbacks.  In API versions 20 and above, the listener object may be used for
+     * another service registration once the callback has been called.  In API versions <= 19,
+     * there is no entirely reliable way to know when a listener may be re-used, and a new
+     * listener should be created for each service registration request.
+     */
+    public void unregisterService(RegistrationListener listener) {
+        int id = getListenerKey(listener);
+        try {
+            mService.unregisterService(id);
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Initiate service discovery to browse for instances of a service type. Service discovery
+     * consumes network bandwidth and will continue until the application calls
+     * {@link #stopServiceDiscovery}.
+     *
+     * <p> The function call immediately returns after sending a request to start service
+     * discovery to the framework. The application is notified of a success to initiate
+     * discovery through the callback {@link DiscoveryListener#onDiscoveryStarted} or a failure
+     * through {@link DiscoveryListener#onStartDiscoveryFailed}.
+     *
+     * <p> Upon successful start, application is notified when a service is found with
+     * {@link DiscoveryListener#onServiceFound} or when a service is lost with
+     * {@link DiscoveryListener#onServiceLost}.
+     *
+     * <p> Upon failure to start, service discovery is not active and application does
+     * not need to invoke {@link #stopServiceDiscovery}
+     *
+     * <p> The application should call {@link #stopServiceDiscovery} when discovery of this
+     * service type is no longer required, and/or whenever the application is paused or
+     * stopped.
+     *
+     * @param serviceType The service type being discovered. Examples include "_http._tcp" for
+     * http services or "_ipp._tcp" for printers
+     * @param protocolType The service discovery protocol
+     * @param listener  The listener notifies of a successful discovery and is used
+     * to stop discovery on this serviceType through a call on {@link #stopServiceDiscovery}.
+     * Cannot be null. Cannot be in use for an active service discovery.
+     */
+    public void discoverServices(String serviceType, int protocolType, DiscoveryListener listener) {
+        discoverServices(serviceType, protocolType, (Network) null, Runnable::run, listener);
+    }
+
+    /**
+     * Initiate service discovery to browse for instances of a service type. Service discovery
+     * consumes network bandwidth and will continue until the application calls
+     * {@link #stopServiceDiscovery}.
+     *
+     * <p> The function call immediately returns after sending a request to start service
+     * discovery to the framework. The application is notified of a success to initiate
+     * discovery through the callback {@link DiscoveryListener#onDiscoveryStarted} or a failure
+     * through {@link DiscoveryListener#onStartDiscoveryFailed}.
+     *
+     * <p> Upon successful start, application is notified when a service is found with
+     * {@link DiscoveryListener#onServiceFound} or when a service is lost with
+     * {@link DiscoveryListener#onServiceLost}.
+     *
+     * <p> Upon failure to start, service discovery is not active and application does
+     * not need to invoke {@link #stopServiceDiscovery}
+     *
+     * <p> The application should call {@link #stopServiceDiscovery} when discovery of this
+     * service type is no longer required, and/or whenever the application is paused or
+     * stopped.
+     * @param serviceType The service type being discovered. Examples include "_http._tcp" for
+     * http services or "_ipp._tcp" for printers
+     * @param protocolType The service discovery protocol
+     * @param network Network to discover services on, or null to discover on all available networks
+     * @param executor Executor to run listener callbacks with
+     * @param listener  The listener notifies of a successful discovery and is used
+     * to stop discovery on this serviceType through a call on {@link #stopServiceDiscovery}.
+     */
+    public void discoverServices(@NonNull String serviceType, int protocolType,
+            @Nullable Network network, @NonNull Executor executor,
+            @NonNull DiscoveryListener listener) {
+        if (TextUtils.isEmpty(serviceType)) {
+            throw new IllegalArgumentException("Service type cannot be empty");
+        }
+        checkProtocol(protocolType);
+
+        NsdServiceInfo s = new NsdServiceInfo();
+        s.setServiceType(serviceType);
+        s.setNetwork(network);
+
+        int key = putListener(listener, executor, s);
+        try {
+            mService.discoverServices(key, s);
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Initiate service discovery to browse for instances of a service type. Service discovery
+     * consumes network bandwidth and will continue until the application calls
+     * {@link #stopServiceDiscovery}.
+     *
+     * <p> The function call immediately returns after sending a request to start service
+     * discovery to the framework. The application is notified of a success to initiate
+     * discovery through the callback {@link DiscoveryListener#onDiscoveryStarted} or a failure
+     * through {@link DiscoveryListener#onStartDiscoveryFailed}.
+     *
+     * <p> Upon successful start, application is notified when a service is found with
+     * {@link DiscoveryListener#onServiceFound} or when a service is lost with
+     * {@link DiscoveryListener#onServiceLost}.
+     *
+     * <p> Upon failure to start, service discovery is not active and application does
+     * not need to invoke {@link #stopServiceDiscovery}
+     *
+     * <p> The application should call {@link #stopServiceDiscovery} when discovery of this
+     * service type is no longer required, and/or whenever the application is paused or
+     * stopped.
+     *
+     * <p> During discovery, new networks may connect or existing networks may disconnect - for
+     * example if wifi is reconnected. When a service was found on a network that disconnects,
+     * {@link DiscoveryListener#onServiceLost} will be called. If a new network connects that
+     * matches the {@link NetworkRequest}, {@link DiscoveryListener#onServiceFound} will be called
+     * for services found on that network. Applications that do not want to track networks
+     * themselves are encouraged to use this method instead of other overloads of
+     * {@code discoverServices}, as they will receive proper notifications when a service becomes
+     * available or unavailable due to network changes.
+     * @param serviceType The service type being discovered. Examples include "_http._tcp" for
+     * http services or "_ipp._tcp" for printers
+     * @param protocolType The service discovery protocol
+     * @param networkRequest Request specifying networks that should be considered when discovering
+     * @param executor Executor to run listener callbacks with
+     * @param listener  The listener notifies of a successful discovery and is used
+     * to stop discovery on this serviceType through a call on {@link #stopServiceDiscovery}.
+     */
+    @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
+    public void discoverServices(@NonNull String serviceType, int protocolType,
+            @NonNull NetworkRequest networkRequest, @NonNull Executor executor,
+            @NonNull DiscoveryListener listener) {
+        if (TextUtils.isEmpty(serviceType)) {
+            throw new IllegalArgumentException("Service type cannot be empty");
+        }
+        Objects.requireNonNull(networkRequest, "NetworkRequest cannot be null");
+        checkProtocol(protocolType);
+
+        NsdServiceInfo s = new NsdServiceInfo();
+        s.setServiceType(serviceType);
+
+        final int baseListenerKey = putListener(listener, executor, s);
+
+        final PerNetworkDiscoveryTracker discoveryInfo = new PerNetworkDiscoveryTracker(
+                serviceType, protocolType, executor, listener);
+
+        synchronized (mPerNetworkDiscoveryMap) {
+            mPerNetworkDiscoveryMap.put(baseListenerKey, discoveryInfo);
+            discoveryInfo.start(networkRequest);
+        }
+    }
+
+    /**
+     * Stop service discovery initiated with {@link #discoverServices}.  An active service
+     * discovery is notified to the application with {@link DiscoveryListener#onDiscoveryStarted}
+     * and it stays active until the application invokes a stop service discovery. A successful
+     * stop is notified to with a call to {@link DiscoveryListener#onDiscoveryStopped}.
+     *
+     * <p> Upon failure to stop service discovery, application is notified through
+     * {@link DiscoveryListener#onStopDiscoveryFailed}.
+     *
+     * @param listener This should be the listener object that was passed to {@link #discoverServices}.
+     * It identifies the discovery that should be stopped and notifies of a successful or
+     * unsuccessful stop.  In API versions 20 and above, the listener object may be used for
+     * another service discovery once the callback has been called.  In API versions <= 19,
+     * there is no entirely reliable way to know when a listener may be re-used, and a new
+     * listener should be created for each service discovery request.
+     */
+    public void stopServiceDiscovery(DiscoveryListener listener) {
+        int id = getListenerKey(listener);
+        // If this is a PerNetworkDiscovery request, handle it as such
+        synchronized (mPerNetworkDiscoveryMap) {
+            final PerNetworkDiscoveryTracker info = mPerNetworkDiscoveryMap.get(id);
+            if (info != null) {
+                info.requestStop();
+                return;
+            }
+        }
+        try {
+            mService.stopDiscovery(id);
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Resolve a discovered service. An application can resolve a service right before
+     * establishing a connection to fetch the IP and port details on which to setup
+     * the connection.
+     *
+     * @param serviceInfo service to be resolved
+     * @param listener to receive callback upon success or failure. Cannot be null.
+     * Cannot be in use for an active service resolution.
+     */
+    public void resolveService(NsdServiceInfo serviceInfo, ResolveListener listener) {
+        resolveService(serviceInfo, Runnable::run, listener);
+    }
+
+    /**
+     * Resolve a discovered service. An application can resolve a service right before
+     * establishing a connection to fetch the IP and port details on which to setup
+     * the connection.
+     * @param serviceInfo service to be resolved
+     * @param executor Executor to run listener callbacks with
+     * @param listener to receive callback upon success or failure.
+     */
+    public void resolveService(@NonNull NsdServiceInfo serviceInfo,
+            @NonNull Executor executor, @NonNull ResolveListener listener) {
+        checkServiceInfo(serviceInfo);
+        int key = putListener(listener, executor, serviceInfo);
+        try {
+            mService.resolveService(key, serviceInfo);
+        } catch (RemoteException e) {
+            e.rethrowFromSystemServer();
+        }
+    }
+
+    private static void checkListener(Object listener) {
+        Objects.requireNonNull(listener, "listener cannot be null");
+    }
+
+    private static void checkProtocol(int protocolType) {
+        if (protocolType != PROTOCOL_DNS_SD) {
+            throw new IllegalArgumentException("Unsupported protocol");
+        }
+    }
+
+    private static void checkServiceInfo(NsdServiceInfo serviceInfo) {
+        Objects.requireNonNull(serviceInfo, "NsdServiceInfo cannot be null");
+        if (TextUtils.isEmpty(serviceInfo.getServiceName())) {
+            throw new IllegalArgumentException("Service name cannot be empty");
+        }
+        if (TextUtils.isEmpty(serviceInfo.getServiceType())) {
+            throw new IllegalArgumentException("Service type cannot be empty");
+        }
+    }
+}
diff --git a/framework-t/src/android/net/nsd/NsdServiceInfo.java b/framework-t/src/android/net/nsd/NsdServiceInfo.java
new file mode 100644
index 0000000..8506db1
--- /dev/null
+++ b/framework-t/src/android/net/nsd/NsdServiceInfo.java
@@ -0,0 +1,418 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.nsd;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.compat.annotation.UnsupportedAppUsage;
+import android.net.Network;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.ArrayMap;
+import android.util.Base64;
+import android.util.Log;
+
+import java.io.UnsupportedEncodingException;
+import java.net.InetAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * A class representing service information for network service discovery
+ * {@see NsdManager}
+ */
+public final class NsdServiceInfo implements Parcelable {
+
+    private static final String TAG = "NsdServiceInfo";
+
+    private String mServiceName;
+
+    private String mServiceType;
+
+    private final ArrayMap<String, byte[]> mTxtRecord = new ArrayMap<>();
+
+    private InetAddress mHost;
+
+    private int mPort;
+
+    @Nullable
+    private Network mNetwork;
+
+    public NsdServiceInfo() {
+    }
+
+    /** @hide */
+    public NsdServiceInfo(String sn, String rt) {
+        mServiceName = sn;
+        mServiceType = rt;
+    }
+
+    /** Get the service name */
+    public String getServiceName() {
+        return mServiceName;
+    }
+
+    /** Set the service name */
+    public void setServiceName(String s) {
+        mServiceName = s;
+    }
+
+    /** Get the service type */
+    public String getServiceType() {
+        return mServiceType;
+    }
+
+    /** Set the service type */
+    public void setServiceType(String s) {
+        mServiceType = s;
+    }
+
+    /** Get the host address. The host address is valid for a resolved service. */
+    public InetAddress getHost() {
+        return mHost;
+    }
+
+    /** Set the host address */
+    public void setHost(InetAddress s) {
+        mHost = s;
+    }
+
+    /** Get port number. The port number is valid for a resolved service. */
+    public int getPort() {
+        return mPort;
+    }
+
+    /** Set port number */
+    public void setPort(int p) {
+        mPort = p;
+    }
+
+    /**
+     * Unpack txt information from a base-64 encoded byte array.
+     *
+     * @param rawRecords The raw base64 encoded records string read from netd.
+     *
+     * @hide
+     */
+    public void setTxtRecords(@NonNull String rawRecords) {
+        byte[] txtRecordsRawBytes = Base64.decode(rawRecords, Base64.DEFAULT);
+
+        // There can be multiple TXT records after each other. Each record has to following format:
+        //
+        // byte                  type                  required   meaning
+        // -------------------   -------------------   --------   ----------------------------------
+        // 0                     unsigned 8 bit        yes        size of record excluding this byte
+        // 1 - n                 ASCII but not '='     yes        key
+        // n + 1                 '='                   optional   separator of key and value
+        // n + 2 - record size   uninterpreted bytes   optional   value
+        //
+        // Example legal records:
+        // [11, 'm', 'y', 'k', 'e', 'y', '=', 0x0, 0x4, 0x65, 0x7, 0xff]
+        // [17, 'm', 'y', 'K', 'e', 'y', 'W', 'i', 't', 'h', 'N', 'o', 'V', 'a', 'l', 'u', 'e', '=']
+        // [12, 'm', 'y', 'B', 'o', 'o', 'l', 'e', 'a', 'n', 'K', 'e', 'y']
+        //
+        // Example corrupted records
+        // [3, =, 1, 2]    <- key is empty
+        // [3, 0, =, 2]    <- key contains non-ASCII character. We handle this by replacing the
+        //                    invalid characters instead of skipping the record.
+        // [30, 'a', =, 2] <- length exceeds total left over bytes in the TXT records array, we
+        //                    handle this by reducing the length of the record as needed.
+        int pos = 0;
+        while (pos < txtRecordsRawBytes.length) {
+            // recordLen is an unsigned 8 bit value
+            int recordLen = txtRecordsRawBytes[pos] & 0xff;
+            pos += 1;
+
+            try {
+                if (recordLen == 0) {
+                    throw new IllegalArgumentException("Zero sized txt record");
+                } else if (pos + recordLen > txtRecordsRawBytes.length) {
+                    Log.w(TAG, "Corrupt record length (pos = " + pos + "): " + recordLen);
+                    recordLen = txtRecordsRawBytes.length - pos;
+                }
+
+                // Decode key-value records
+                String key = null;
+                byte[] value = null;
+                int valueLen = 0;
+                for (int i = pos; i < pos + recordLen; i++) {
+                    if (key == null) {
+                        if (txtRecordsRawBytes[i] == '=') {
+                            key = new String(txtRecordsRawBytes, pos, i - pos,
+                                    StandardCharsets.US_ASCII);
+                        }
+                    } else {
+                        if (value == null) {
+                            value = new byte[recordLen - key.length() - 1];
+                        }
+                        value[valueLen] = txtRecordsRawBytes[i];
+                        valueLen++;
+                    }
+                }
+
+                // If '=' was not found we have a boolean record
+                if (key == null) {
+                    key = new String(txtRecordsRawBytes, pos, recordLen, StandardCharsets.US_ASCII);
+                }
+
+                if (TextUtils.isEmpty(key)) {
+                    // Empty keys are not allowed (RFC6763 6.4)
+                    throw new IllegalArgumentException("Invalid txt record (key is empty)");
+                }
+
+                if (getAttributes().containsKey(key)) {
+                    // When we have a duplicate record, the later ones are ignored (RFC6763 6.4)
+                    throw new IllegalArgumentException("Invalid txt record (duplicate key \"" + key + "\")");
+                }
+
+                setAttribute(key, value);
+            } catch (IllegalArgumentException e) {
+                Log.e(TAG, "While parsing txt records (pos = " + pos + "): " + e.getMessage());
+            }
+
+            pos += recordLen;
+        }
+    }
+
+    /** @hide */
+    @UnsupportedAppUsage
+    public void setAttribute(String key, byte[] value) {
+        if (TextUtils.isEmpty(key)) {
+            throw new IllegalArgumentException("Key cannot be empty");
+        }
+
+        // Key must be printable US-ASCII, excluding =.
+        for (int i = 0; i < key.length(); ++i) {
+            char character = key.charAt(i);
+            if (character < 0x20 || character > 0x7E) {
+                throw new IllegalArgumentException("Key strings must be printable US-ASCII");
+            } else if (character == 0x3D) {
+                throw new IllegalArgumentException("Key strings must not include '='");
+            }
+        }
+
+        // Key length + value length must be < 255.
+        if (key.length() + (value == null ? 0 : value.length) >= 255) {
+            throw new IllegalArgumentException("Key length + value length must be < 255 bytes");
+        }
+
+        // Warn if key is > 9 characters, as recommended by RFC 6763 section 6.4.
+        if (key.length() > 9) {
+            Log.w(TAG, "Key lengths > 9 are discouraged: " + key);
+        }
+
+        // Check against total TXT record size limits.
+        // Arbitrary 400 / 1300 byte limits taken from RFC 6763 section 6.2.
+        int txtRecordSize = getTxtRecordSize();
+        int futureSize = txtRecordSize + key.length() + (value == null ? 0 : value.length) + 2;
+        if (futureSize > 1300) {
+            throw new IllegalArgumentException("Total length of attributes must be < 1300 bytes");
+        } else if (futureSize > 400) {
+            Log.w(TAG, "Total length of all attributes exceeds 400 bytes; truncation may occur");
+        }
+
+        mTxtRecord.put(key, value);
+    }
+
+    /**
+     * Add a service attribute as a key/value pair.
+     *
+     * <p> Service attributes are included as DNS-SD TXT record pairs.
+     *
+     * <p> The key must be US-ASCII printable characters, excluding the '=' character.  Values may
+     * be UTF-8 strings or null.  The total length of key + value must be less than 255 bytes.
+     *
+     * <p> Keys should be short, ideally no more than 9 characters, and unique per instance of
+     * {@link NsdServiceInfo}.  Calling {@link #setAttribute} twice with the same key will overwrite
+     * first value.
+     */
+    public void setAttribute(String key, String value) {
+        try {
+            setAttribute(key, value == null ? (byte []) null : value.getBytes("UTF-8"));
+        } catch (UnsupportedEncodingException e) {
+            throw new IllegalArgumentException("Value must be UTF-8");
+        }
+    }
+
+    /** Remove an attribute by key */
+    public void removeAttribute(String key) {
+        mTxtRecord.remove(key);
+    }
+
+    /**
+     * Retrieve attributes as a map of String keys to byte[] values. The attributes map is only
+     * valid for a resolved service.
+     *
+     * <p> The returned map is unmodifiable; changes must be made through {@link #setAttribute} and
+     * {@link #removeAttribute}.
+     */
+    public Map<String, byte[]> getAttributes() {
+        return Collections.unmodifiableMap(mTxtRecord);
+    }
+
+    private int getTxtRecordSize() {
+        int txtRecordSize = 0;
+        for (Map.Entry<String, byte[]> entry : mTxtRecord.entrySet()) {
+            txtRecordSize += 2;  // One for the length byte, one for the = between key and value.
+            txtRecordSize += entry.getKey().length();
+            byte[] value = entry.getValue();
+            txtRecordSize += value == null ? 0 : value.length;
+        }
+        return txtRecordSize;
+    }
+
+    /** @hide */
+    public @NonNull byte[] getTxtRecord() {
+        int txtRecordSize = getTxtRecordSize();
+        if (txtRecordSize == 0) {
+            return new byte[]{};
+        }
+
+        byte[] txtRecord = new byte[txtRecordSize];
+        int ptr = 0;
+        for (Map.Entry<String, byte[]> entry : mTxtRecord.entrySet()) {
+            String key = entry.getKey();
+            byte[] value = entry.getValue();
+
+            // One byte to record the length of this key/value pair.
+            txtRecord[ptr++] = (byte) (key.length() + (value == null ? 0 : value.length) + 1);
+
+            // The key, in US-ASCII.
+            // Note: use the StandardCharsets const here because it doesn't raise exceptions and we
+            // already know the key is ASCII at this point.
+            System.arraycopy(key.getBytes(StandardCharsets.US_ASCII), 0, txtRecord, ptr,
+                    key.length());
+            ptr += key.length();
+
+            // US-ASCII '=' character.
+            txtRecord[ptr++] = (byte)'=';
+
+            // The value, as any raw bytes.
+            if (value != null) {
+                System.arraycopy(value, 0, txtRecord, ptr, value.length);
+                ptr += value.length;
+            }
+        }
+        return txtRecord;
+    }
+
+    /**
+     * Get the network where the service can be found.
+     *
+     * This is never null if this {@link NsdServiceInfo} was obtained from
+     * {@link NsdManager#discoverServices} or {@link NsdManager#resolveService}.
+     */
+    @Nullable
+    public Network getNetwork() {
+        return mNetwork;
+    }
+
+    /**
+     * Set the network where the service can be found.
+     * @param network The network, or null to search for, or to announce, the service on all
+     *                connected networks.
+     */
+    public void setNetwork(@Nullable Network network) {
+        mNetwork = network;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("name: ").append(mServiceName)
+                .append(", type: ").append(mServiceType)
+                .append(", host: ").append(mHost)
+                .append(", port: ").append(mPort)
+                .append(", network: ").append(mNetwork);
+
+        byte[] txtRecord = getTxtRecord();
+        sb.append(", txtRecord: ").append(new String(txtRecord, StandardCharsets.UTF_8));
+        return sb.toString();
+    }
+
+    /** Implement the Parcelable interface */
+    public int describeContents() {
+        return 0;
+    }
+
+    /** Implement the Parcelable interface */
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(mServiceName);
+        dest.writeString(mServiceType);
+        if (mHost != null) {
+            dest.writeInt(1);
+            dest.writeByteArray(mHost.getAddress());
+        } else {
+            dest.writeInt(0);
+        }
+        dest.writeInt(mPort);
+
+        // TXT record key/value pairs.
+        dest.writeInt(mTxtRecord.size());
+        for (String key : mTxtRecord.keySet()) {
+            byte[] value = mTxtRecord.get(key);
+            if (value != null) {
+                dest.writeInt(1);
+                dest.writeInt(value.length);
+                dest.writeByteArray(value);
+            } else {
+                dest.writeInt(0);
+            }
+            dest.writeString(key);
+        }
+
+        dest.writeParcelable(mNetwork, 0);
+    }
+
+    /** Implement the Parcelable interface */
+    public static final @android.annotation.NonNull Creator<NsdServiceInfo> CREATOR =
+        new Creator<NsdServiceInfo>() {
+            public NsdServiceInfo createFromParcel(Parcel in) {
+                NsdServiceInfo info = new NsdServiceInfo();
+                info.mServiceName = in.readString();
+                info.mServiceType = in.readString();
+
+                if (in.readInt() == 1) {
+                    try {
+                        info.mHost = InetAddress.getByAddress(in.createByteArray());
+                    } catch (java.net.UnknownHostException e) {}
+                }
+
+                info.mPort = in.readInt();
+
+                // TXT record key/value pairs.
+                int recordCount = in.readInt();
+                for (int i = 0; i < recordCount; ++i) {
+                    byte[] valueArray = null;
+                    if (in.readInt() == 1) {
+                        int valueLength = in.readInt();
+                        valueArray = new byte[valueLength];
+                        in.readByteArray(valueArray);
+                    }
+                    info.mTxtRecord.put(in.readString(), valueArray);
+                }
+                info.mNetwork = in.readParcelable(null, Network.class);
+                return info;
+            }
+
+            public NsdServiceInfo[] newArray(int size) {
+                return new NsdServiceInfo[size];
+            }
+        };
+}
diff --git a/framework/aidl-export/android/net/NetworkStats.aidl b/framework/aidl-export/android/net/NetworkStats.aidl
new file mode 100644
index 0000000..d06ca65
--- /dev/null
+++ b/framework/aidl-export/android/net/NetworkStats.aidl
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2011, 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;
+
+parcelable NetworkStats;
diff --git a/framework/aidl-export/android/net/NetworkTemplate.aidl b/framework/aidl-export/android/net/NetworkTemplate.aidl
new file mode 100644
index 0000000..3d37488
--- /dev/null
+++ b/framework/aidl-export/android/net/NetworkTemplate.aidl
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2011, 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;
+
+parcelable NetworkTemplate;
diff --git a/framework/aidl-export/android/net/nsd/NsdServiceInfo.aidl b/framework/aidl-export/android/net/nsd/NsdServiceInfo.aidl
new file mode 100644
index 0000000..657bdd1
--- /dev/null
+++ b/framework/aidl-export/android/net/nsd/NsdServiceInfo.aidl
@@ -0,0 +1,19 @@
+/*
+ * 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 android.net.nsd;
+
+@JavaOnlyStableParcelable parcelable NsdServiceInfo;
\ No newline at end of file
diff --git a/framework/api/module-lib-current.txt b/framework/api/module-lib-current.txt
index e4e2151..ddac19d 100644
--- a/framework/api/module-lib-current.txt
+++ b/framework/api/module-lib-current.txt
@@ -30,10 +30,10 @@
     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.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();
-    method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void updateFirewallRule(int, int, boolean);
     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";
@@ -54,6 +54,9 @@
     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_NO_FALLBACK = 2; // 0x2
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index a798f6e..e25a855 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -995,6 +995,36 @@
     // LINT.ThenChange(packages/modules/Connectivity/service/native/include/Common.h)
 
     /**
+     * Specify default rule which may allow or drop packets depending on existing policy.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int FIREWALL_RULE_DEFAULT = 0;
+
+    /**
+     * Specify allow rule which allows packets.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int FIREWALL_RULE_ALLOW = 1;
+
+    /**
+     * Specify deny rule which drops packets.
+     * @hide
+     */
+    @SystemApi(client = MODULE_LIBRARIES)
+    public static final int FIREWALL_RULE_DENY = 2;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(flag = false, prefix = "FIREWALL_RULE_", value = {
+        FIREWALL_RULE_DEFAULT,
+        FIREWALL_RULE_ALLOW,
+        FIREWALL_RULE_DENY
+    })
+    public @interface FirewallRule {}
+
+    /**
      * A kludge to facilitate static access where a Context pointer isn't available, like in the
      * case of the static set/getProcessDefaultNetwork methods and from the Network class.
      * TODO: Remove this after deprecating the static methods in favor of non-static methods or
@@ -5802,8 +5832,9 @@
      *
      * @param chain target chain.
      * @param uid uid to allow/deny.
-     * @param allow whether networking is allowed or denied.
+     * @param rule firewall rule to allow/drop packets.
      * @throws IllegalStateException if updating firewall rule failed.
+     * @throws IllegalArgumentException if {@code rule} is not a valid rule.
      * @hide
      */
     @SystemApi(client = MODULE_LIBRARIES)
@@ -5812,10 +5843,10 @@
             android.Manifest.permission.NETWORK_STACK,
             NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK
     })
-    public void updateFirewallRule(@FirewallChain final int chain, final int uid,
-            final boolean allow) {
+    public void setUidFirewallRule(@FirewallChain final int chain, final int uid,
+            @FirewallRule final int rule) {
         try {
-            mService.updateFirewallRule(chain, uid, allow);
+            mService.setUidFirewallRule(chain, uid, rule);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl
index 0988bf3..bc73769 100644
--- a/framework/src/android/net/IConnectivityManager.aidl
+++ b/framework/src/android/net/IConnectivityManager.aidl
@@ -240,7 +240,7 @@
 
     void updateMeteredNetworkDenyList(int uid, boolean add);
 
-    void updateFirewallRule(int chain, int uid, boolean allow);
+    void setUidFirewallRule(int chain, int uid, int rule);
 
     void setFirewallChainEnabled(int chain, boolean enable);
 
diff --git a/nearby/.gitignore b/nearby/.gitignore
new file mode 100644
index 0000000..4402b3d
--- /dev/null
+++ b/nearby/.gitignore
@@ -0,0 +1,8 @@
+# Eclipse project
+**/.classpath
+**/.project
+
+# IntelliJ project
+**/.idea
+**/*.iml
+**/*.ipr
\ No newline at end of file
diff --git a/nearby/Android.bp b/nearby/Android.bp
deleted file mode 100644
index fb4e3cd..0000000
--- a/nearby/Android.bp
+++ /dev/null
@@ -1,39 +0,0 @@
-//
-// Copyright (C) 2022 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-package {
-    // See: http://go/android-license-faq
-    default_applicable_licenses: ["Android-Apache-2.0"],
-}
-
-// Empty sources and libraries to avoid merge conflicts with downstream
-// branches
-// TODO: remove once the Nearby sources are available in this branch
-filegroup {
-    name: "framework-nearby-java-sources",
-    srcs: [],
-    visibility: ["//packages/modules/Connectivity:__subpackages__"],
-}
-
-
-java_library {
-    name: "service-nearby-pre-jarjar",
-    srcs: ["service-src/**/*.java"],
-    sdk_version: "module_current",
-    min_sdk_version: "30",
-    apex_available: ["com.android.tethering"],
-    visibility: ["//packages/modules/Connectivity:__subpackages__"],
-}
diff --git a/nearby/PREUPLOAD.cfg b/nearby/PREUPLOAD.cfg
new file mode 100644
index 0000000..048ddb6
--- /dev/null
+++ b/nearby/PREUPLOAD.cfg
@@ -0,0 +1,10 @@
+[Builtin Hooks]
+xmllint = true
+clang_format = true
+commit_msg_changeid_field = true
+
+[Builtin Hooks Options]
+clang_format = --commit ${PREUPLOAD_COMMIT} --style file --extensions c,h,cc,cpp
+
+[Hook Scripts]
+checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py --sha ${PREUPLOAD_COMMIT}
\ No newline at end of file
diff --git a/nearby/README.md b/nearby/README.md
new file mode 100644
index 0000000..6925dc4
--- /dev/null
+++ b/nearby/README.md
@@ -0,0 +1,42 @@
+# Nearby Mainline Module
+This directory contains code for the AOSP Nearby mainline module.
+
+##Directory Structure
+
+`apex`
+ - Files associated with the Nearby mainline module APEX.
+
+`framework`
+ - Contains client side APIs and AIDL files.
+
+`jni`
+ - JNI wrapper for invoking Android APIs from native code.
+
+`native`
+ - Native code implementation for nearby module services.
+
+`service`
+ - Server side implementation for nearby module services.
+
+`tests`
+ - Unit/Multi devices tests for Nearby module (both Java and native code).
+
+## IDE setup
+
+```sh
+$ source build/envsetup.sh && lunch <TARGET>
+$ cd packages/modules/Nearby
+$ aidegen .
+# This will launch Intellij project for Nearby module.
+```
+
+## Build and Install
+
+```sh
+$ source build/envsetup.sh && lunch <TARGET>
+$ m com.google.android.tethering.next deapexer
+$ $ANDROID_BUILD_TOP/out/host/linux-x86/bin/deapexer decompress --input \
+    ${ANDROID_PRODUCT_OUT}/system/apex/com.google.android.tethering.next.capex \
+    --output /tmp/tethering.apex
+$ adb install -r /tmp/tethering.apex
+```
diff --git a/nearby/TEST_MAPPING b/nearby/TEST_MAPPING
new file mode 100644
index 0000000..dbaca33
--- /dev/null
+++ b/nearby/TEST_MAPPING
@@ -0,0 +1,24 @@
+{
+  "presubmit": [
+    {
+      "name": "NearbyUnitTests"
+    },
+    {
+      "name": "NearbyIntegrationPrivilegedTests"
+    },
+    {
+      "name": "NearbyIntegrationUntrustedTests"
+    }
+  ],
+  "postsubmit": [
+    {
+      "name": "NearbyUnitTests"
+    }
+  ]
+  // TODO(b/193602229): uncomment once it's supported.
+  //"mainline-presubmit": [
+  //  {
+  //    "name": "NearbyUnitTests[com.google.android.nearby.apex]"
+  //  }
+  //]
+}
diff --git a/nearby/apex/Android.bp b/nearby/apex/Android.bp
new file mode 100644
index 0000000..d7f063a
--- /dev/null
+++ b/nearby/apex/Android.bp
@@ -0,0 +1,21 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+filegroup {
+    name: "nearby-jarjar-rules",
+    srcs: ["jarjar-rules.txt"],
+}
diff --git a/nearby/apex/jarjar-rules.txt b/nearby/apex/jarjar-rules.txt
new file mode 100644
index 0000000..826f54f
--- /dev/null
+++ b/nearby/apex/jarjar-rules.txt
@@ -0,0 +1 @@
+rule com.android.internal.** com.android.nearby.jarjar.@0
diff --git a/nearby/apex/manifest.json b/nearby/apex/manifest.json
new file mode 100644
index 0000000..b91d259
--- /dev/null
+++ b/nearby/apex/manifest.json
@@ -0,0 +1,4 @@
+{
+  "name": "com.android.nearby",
+  "version": 1
+}
diff --git a/nearby/framework/Android.bp b/nearby/framework/Android.bp
new file mode 100644
index 0000000..e223b54
--- /dev/null
+++ b/nearby/framework/Android.bp
@@ -0,0 +1,55 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+// Sources included in the framework-connectivity-t jar
+// TODO: consider moving files to packages/modules/Connectivity
+filegroup {
+    name: "framework-nearby-java-sources",
+    srcs: [
+        "java/**/*.java",
+        "java/**/*.aidl",
+    ],
+    path: "java",
+    visibility: [
+        "//packages/modules/Connectivity/framework-t:__subpackages__",
+    ],
+}
+
+filegroup {
+    name: "framework-nearby-sources",
+    srcs: [
+        ":framework-nearby-java-sources",
+    ],
+    visibility: ["//frameworks/base"],
+}
+
+// Build of only framework-nearby (not as part of connectivity) for
+// unit tests
+java_library {
+    name: "framework-nearby-static",
+    srcs: [":framework-nearby-java-sources"],
+    sdk_version: "module_current",
+    libs: [
+        "framework-annotations-lib",
+        "framework-bluetooth",
+    ],
+    static_libs: [
+        "modules-utils-preconditions",
+    ],
+    visibility: ["//packages/modules/Connectivity/nearby/tests:__subpackages__"],
+}
diff --git a/nearby/framework/java/android/nearby/BroadcastCallback.java b/nearby/framework/java/android/nearby/BroadcastCallback.java
new file mode 100644
index 0000000..cc94308
--- /dev/null
+++ b/nearby/framework/java/android/nearby/BroadcastCallback.java
@@ -0,0 +1,64 @@
+/*
+ * 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 android.annotation.IntDef;
+import android.annotation.SystemApi;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Callback when broadcasting request using nearby specification.
+ *
+ * @hide
+ */
+@SystemApi
+public interface BroadcastCallback {
+    /** Broadcast was successful. */
+    int STATUS_OK = 0;
+
+    /** General status code when broadcast failed. */
+    int STATUS_FAILURE = 1;
+
+    /**
+     * Broadcast failed as the callback was already registered.
+     */
+    int STATUS_FAILURE_ALREADY_REGISTERED = 2;
+
+    /**
+     * Broadcast failed as the request contains excessive data.
+     */
+    int STATUS_FAILURE_SIZE_EXCEED_LIMIT = 3;
+
+    /**
+     * Broadcast failed as the client doesn't hold required permissions.
+     */
+    int STATUS_FAILURE_MISSING_PERMISSIONS = 4;
+
+    /** @hide **/
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({STATUS_OK, STATUS_FAILURE, STATUS_FAILURE_ALREADY_REGISTERED,
+            STATUS_FAILURE_SIZE_EXCEED_LIMIT, STATUS_FAILURE_MISSING_PERMISSIONS})
+    @interface BroadcastStatus {
+    }
+
+    /**
+     * Called when broadcast status changes.
+     */
+    void onStatusChanged(@BroadcastStatus int status);
+}
diff --git a/nearby/framework/java/android/nearby/BroadcastRequest.java b/nearby/framework/java/android/nearby/BroadcastRequest.java
new file mode 100644
index 0000000..3273ea1
--- /dev/null
+++ b/nearby/framework/java/android/nearby/BroadcastRequest.java
@@ -0,0 +1,171 @@
+/*
+ * 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 android.annotation.IntDef;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a {@link BroadcastRequest}.
+ *
+ * @hide
+ */
+@SystemApi
+public abstract class BroadcastRequest {
+
+    /** An unknown nearby broadcast request type. */
+    public static final int BROADCAST_TYPE_UNKNOWN = -1;
+
+    /** Broadcast type for advertising using nearby presence protocol. */
+    public static final int BROADCAST_TYPE_NEARBY_PRESENCE = 3;
+
+    /** @hide **/
+    // Currently, only Nearby Presence broadcast is supported, in the future
+    // broadcasting using other nearby specifications will be added.
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({BROADCAST_TYPE_UNKNOWN, BROADCAST_TYPE_NEARBY_PRESENCE})
+    public @interface BroadcastType {
+    }
+
+    /**
+     * Tx Power when the value is not set in the broadcast.
+     */
+    public static final int UNKNOWN_TX_POWER = -127;
+
+    /**
+     * An unknown version of presence broadcast request.
+     */
+    public static final int PRESENCE_VERSION_UNKNOWN = -1;
+
+    /**
+     * A legacy presence version that is only suitable for legacy (31 bytes) BLE advertisements.
+     * This exists to support legacy presence version, and not recommended for use.
+     */
+    public static final int PRESENCE_VERSION_V0 = 0;
+
+    /**
+     * V1 of Nearby Presence Protocol. This version supports both legacy (31 bytes) BLE
+     * advertisements, and extended BLE advertisements.
+     */
+    public static final int PRESENCE_VERSION_V1 = 1;
+
+    /** @hide **/
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({PRESENCE_VERSION_UNKNOWN, PRESENCE_VERSION_V0, PRESENCE_VERSION_V1})
+    public @interface BroadcastVersion {
+    }
+
+    /**
+     * The medium where the broadcast request should be sent.
+     *
+     * @hide
+     */
+    @IntDef({Medium.BLE, Medium.MDNS})
+    public @interface Medium {
+        int BLE = 1;
+        int MDNS = 2;
+    }
+
+    /**
+     * Creates a {@link BroadcastRequest} from parcel.
+     *
+     * @hide
+     */
+    @NonNull
+    public static BroadcastRequest createFromParcel(Parcel in) {
+        int type = in.readInt();
+        switch (type) {
+            case BroadcastRequest.BROADCAST_TYPE_NEARBY_PRESENCE:
+                return PresenceBroadcastRequest.createFromParcelBody(in);
+            default:
+                throw new IllegalStateException(
+                        "Unexpected broadcast type (value " + type + ") in parcel.");
+        }
+    }
+
+    private final @BroadcastType int mType;
+    private final @BroadcastVersion int mVersion;
+    private final int mTxPower;
+    private final @Medium List<Integer> mMediums;
+
+    BroadcastRequest(@BroadcastType int type, @BroadcastVersion int version, int txPower,
+            @Medium List<Integer> mediums) {
+        this.mType = type;
+        this.mVersion = version;
+        this.mTxPower = txPower;
+        this.mMediums = mediums;
+    }
+
+    BroadcastRequest(@BroadcastType int type, Parcel in) {
+        mType = type;
+        mVersion = in.readInt();
+        mTxPower = in.readInt();
+        mMediums = new ArrayList<>();
+        in.readList(mMediums, Integer.class.getClassLoader(), Integer.class);
+    }
+
+    /**
+     * Returns the type of the broadcast.
+     */
+    public @BroadcastType int getType() {
+        return mType;
+    }
+
+    /**
+     * Returns the version of the broadcast.
+     */
+    public @BroadcastVersion int getVersion() {
+        return mVersion;
+    }
+
+    /**
+     * Returns the calibrated TX power when this request is broadcast.
+     */
+    @IntRange(from = -127, to = 126)
+    public int getTxPower() {
+        return mTxPower;
+    }
+
+    /**
+     * Returns the list of broadcast mediums.
+     */
+    @NonNull
+    @Medium
+    public List<Integer> getMediums() {
+        return mMediums;
+    }
+
+    /**
+     * Writes the BroadcastRequest to the parcel.
+     *
+     * @hide
+     */
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mType);
+        dest.writeInt(mVersion);
+        dest.writeInt(mTxPower);
+        dest.writeList(mMediums);
+    }
+}
diff --git a/nearby/framework/java/android/nearby/BroadcastRequestParcelable.aidl b/nearby/framework/java/android/nearby/BroadcastRequestParcelable.aidl
new file mode 100644
index 0000000..818f8d5
--- /dev/null
+++ b/nearby/framework/java/android/nearby/BroadcastRequestParcelable.aidl
@@ -0,0 +1,19 @@
+/*
+ * 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;
+
+parcelable BroadcastRequestParcelable;
diff --git a/nearby/framework/java/android/nearby/BroadcastRequestParcelable.java b/nearby/framework/java/android/nearby/BroadcastRequestParcelable.java
new file mode 100644
index 0000000..4a2ff6d
--- /dev/null
+++ b/nearby/framework/java/android/nearby/BroadcastRequestParcelable.java
@@ -0,0 +1,64 @@
+/*
+ * 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 android.annotation.NonNull;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * A wrapper of {@link BroadcastRequest} that is parcelable.
+ *
+ * @hide
+ */
+public class BroadcastRequestParcelable implements Parcelable {
+    private final BroadcastRequest mBroadcastRequest;
+
+    public static final Creator<BroadcastRequestParcelable> CREATOR =
+            new Creator<BroadcastRequestParcelable>() {
+                @Override
+                public BroadcastRequestParcelable createFromParcel(Parcel in) {
+                    return new BroadcastRequestParcelable(BroadcastRequest.createFromParcel(in));
+                }
+
+                @Override
+                public BroadcastRequestParcelable[] newArray(int size) {
+                    return new BroadcastRequestParcelable[size];
+                }
+            };
+
+    BroadcastRequestParcelable(BroadcastRequest broadcastRequest) {
+        mBroadcastRequest = broadcastRequest;
+    }
+
+    /**
+     * Returns the broadcastRequest.
+     */
+    public BroadcastRequest getBroadcastRequest() {
+        return mBroadcastRequest;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        mBroadcastRequest.writeToParcel(dest, flags);
+    }
+}
diff --git a/nearby/framework/java/android/nearby/CredentialElement.java b/nearby/framework/java/android/nearby/CredentialElement.java
new file mode 100644
index 0000000..7a43b01
--- /dev/null
+++ b/nearby/framework/java/android/nearby/CredentialElement.java
@@ -0,0 +1,101 @@
+/*
+ * 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 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 an element in {@link PresenceCredential}.
+ *
+ * @hide
+ */
+@SystemApi
+public final class CredentialElement implements Parcelable {
+    private final String mKey;
+    private final byte[] mValue;
+
+    /** Constructs a {@link CredentialElement}. */
+    public CredentialElement(@NonNull String key, @NonNull byte[] value) {
+        Preconditions.checkState(key != null && value != null, "neither key or value can be null");
+        mKey = key;
+        mValue = value;
+    }
+
+    @NonNull
+    public static final Parcelable.Creator<CredentialElement> CREATOR =
+            new Parcelable.Creator<CredentialElement>() {
+                @Override
+                public CredentialElement createFromParcel(Parcel in) {
+                    String key = in.readString();
+                    byte[] value = new byte[in.readInt()];
+                    in.readByteArray(value);
+                    return new CredentialElement(key, value);
+                }
+
+                @Override
+                public CredentialElement[] newArray(int size) {
+                    return new CredentialElement[size];
+                }
+            };
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeString(mKey);
+        dest.writeInt(mValue.length);
+        dest.writeByteArray(mValue);
+    }
+
+    /** Returns the key of the credential element. */
+    @NonNull
+    public String getKey() {
+        return mKey;
+    }
+
+    /** Returns the value of the credential element. */
+    @NonNull
+    public byte[] getValue() {
+        return mValue;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj instanceof CredentialElement) {
+            CredentialElement that = (CredentialElement) obj;
+            return mKey.equals(that.mKey) && Arrays.equals(mValue, that.mValue);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mKey.hashCode(), Arrays.hashCode(mValue));
+    }
+}
diff --git a/nearby/framework/java/android/nearby/DataElement.java b/nearby/framework/java/android/nearby/DataElement.java
new file mode 100644
index 0000000..6fa5fb5
--- /dev/null
+++ b/nearby/framework/java/android/nearby/DataElement.java
@@ -0,0 +1,89 @@
+/*
+ * 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 android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.internal.util.Preconditions;
+
+
+/**
+ * Represents a data element in Nearby Presence.
+ *
+ * @hide
+ */
+@SystemApi
+public final class DataElement implements Parcelable {
+
+    private final int mKey;
+    private final byte[] mValue;
+
+    /**
+     * Constructs a {@link DataElement}.
+     */
+    public DataElement(int key, @NonNull byte[] value) {
+        Preconditions.checkState(value != null, "value cannot be null");
+        mKey = key;
+        mValue = value;
+    }
+
+    @NonNull
+    public static final Creator<DataElement> CREATOR = new Creator<DataElement>() {
+        @Override
+        public DataElement createFromParcel(Parcel in) {
+            int key = in.readInt();
+            byte[] value = new byte[in.readInt()];
+            in.readByteArray(value);
+            return new DataElement(key, value);
+        }
+
+        @Override
+        public DataElement[] newArray(int size) {
+            return new DataElement[size];
+        }
+    };
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mKey);
+        dest.writeInt(mValue.length);
+        dest.writeByteArray(mValue);
+    }
+
+    /**
+     * Returns the key of the data element, as defined in the nearby presence specification.
+     */
+    public int getKey() {
+        return mKey;
+    }
+
+    /**
+     * Returns the value of the data element.
+     */
+    @NonNull
+    public byte[] getValue() {
+        return mValue;
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairAccountKeyDeviceMetadata.java b/nearby/framework/java/android/nearby/FastPairAccountKeyDeviceMetadata.java
new file mode 100644
index 0000000..160ad75
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairAccountKeyDeviceMetadata.java
@@ -0,0 +1,196 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+
+/**
+ * Class for metadata of a Fast Pair device associated with an account.
+ *
+ * @hide
+ */
+@SystemApi
+public class FastPairAccountKeyDeviceMetadata {
+
+    FastPairAccountKeyDeviceMetadataParcel mMetadataParcel;
+
+    FastPairAccountKeyDeviceMetadata(FastPairAccountKeyDeviceMetadataParcel metadataParcel) {
+        this.mMetadataParcel = metadataParcel;
+    }
+
+    /**
+     * Get Device Account Key, which uniquely identifies a Fast Pair device associated with an
+     * account. AccountKey is 16 bytes: first byte is 0x04. Other 15 bytes are randomly generated.
+     *
+     * @return 16-byte Account Key.
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public byte[] getDeviceAccountKey() {
+        return mMetadataParcel.deviceAccountKey;
+    }
+
+    /**
+     * Get a hash value of device's account key and public bluetooth address without revealing the
+     * public bluetooth address. Sha256 hash value is 32 bytes.
+     *
+     * @return 32-byte Sha256 hash value.
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public byte[] getSha256DeviceAccountKeyPublicAddress() {
+        return mMetadataParcel.sha256DeviceAccountKeyPublicAddress;
+    }
+
+    /**
+     * Get metadata of a Fast Pair device type.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public FastPairDeviceMetadata getFastPairDeviceMetadata() {
+        if (mMetadataParcel.metadata == null) {
+            return null;
+        }
+        return new FastPairDeviceMetadata(mMetadataParcel.metadata);
+    }
+
+    /**
+     * Get Fast Pair discovery item, which is tied to both the device type and the account.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public FastPairDiscoveryItem getFastPairDiscoveryItem() {
+        if (mMetadataParcel.discoveryItem == null) {
+            return null;
+        }
+        return new FastPairDiscoveryItem(mMetadataParcel.discoveryItem);
+    }
+
+    /**
+     * Builder used to create FastPairAccountKeyDeviceMetadata.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final class Builder {
+
+        private final FastPairAccountKeyDeviceMetadataParcel mBuilderParcel;
+
+        /**
+         * Default constructor of Builder.
+         *
+         * @hide
+         */
+        @SystemApi
+        public Builder() {
+            mBuilderParcel = new FastPairAccountKeyDeviceMetadataParcel();
+            mBuilderParcel.deviceAccountKey = null;
+            mBuilderParcel.sha256DeviceAccountKeyPublicAddress = null;
+            mBuilderParcel.metadata = null;
+            mBuilderParcel.discoveryItem = null;
+        }
+
+        /**
+         * Set Account Key.
+         *
+         * @param deviceAccountKey Fast Pair device account key, which is 16 bytes: first byte is
+         *                         0x04. Next 15 bytes are randomly generated.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDeviceAccountKey(@Nullable byte[] deviceAccountKey) {
+            mBuilderParcel.deviceAccountKey = deviceAccountKey;
+            return this;
+        }
+
+        /**
+         * Set sha256 hash value of account key and public bluetooth address.
+         *
+         * @param sha256DeviceAccountKeyPublicAddress 32-byte sha256 hash value of account key and
+         *                                            public bluetooth address.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setSha256DeviceAccountKeyPublicAddress(
+                @Nullable byte[] sha256DeviceAccountKeyPublicAddress) {
+            mBuilderParcel.sha256DeviceAccountKeyPublicAddress =
+                    sha256DeviceAccountKeyPublicAddress;
+            return this;
+        }
+
+
+        /**
+         * Set Fast Pair metadata.
+         *
+         * @param metadata Fast Pair metadata.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setFastPairDeviceMetadata(@Nullable FastPairDeviceMetadata metadata) {
+            if (metadata == null) {
+                mBuilderParcel.metadata = null;
+            } else {
+                mBuilderParcel.metadata = metadata.mMetadataParcel;
+            }
+            return this;
+        }
+
+        /**
+         * Set Fast Pair discovery item.
+         *
+         * @param discoveryItem Fast Pair discovery item.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setFastPairDiscoveryItem(@Nullable FastPairDiscoveryItem discoveryItem) {
+            if (discoveryItem == null) {
+                mBuilderParcel.discoveryItem = null;
+            } else {
+                mBuilderParcel.discoveryItem = discoveryItem.mMetadataParcel;
+            }
+            return this;
+        }
+
+        /**
+         * Build {@link FastPairAccountKeyDeviceMetadata} with the currently set configuration.
+         *
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public FastPairAccountKeyDeviceMetadata build() {
+            return new FastPairAccountKeyDeviceMetadata(mBuilderParcel);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairAntispoofKeyDeviceMetadata.java b/nearby/framework/java/android/nearby/FastPairAntispoofKeyDeviceMetadata.java
new file mode 100644
index 0000000..1837671
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairAntispoofKeyDeviceMetadata.java
@@ -0,0 +1,128 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataParcel;
+
+/**
+ * Class for a type of registered Fast Pair device keyed by modelID, or antispoofKey.
+ *
+ * @hide
+ */
+@SystemApi
+public class FastPairAntispoofKeyDeviceMetadata {
+
+    FastPairAntispoofKeyDeviceMetadataParcel mMetadataParcel;
+    FastPairAntispoofKeyDeviceMetadata(
+            FastPairAntispoofKeyDeviceMetadataParcel metadataParcel) {
+        this.mMetadataParcel = metadataParcel;
+    }
+
+    /**
+     * Get Antispoof public key.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public byte[] getAntispoofPublicKey() {
+        return this.mMetadataParcel.antispoofPublicKey;
+    }
+
+    /**
+     * Get metadata of a Fast Pair device type.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public FastPairDeviceMetadata getFastPairDeviceMetadata() {
+        if (this.mMetadataParcel.deviceMetadata == null) {
+            return null;
+        }
+        return new FastPairDeviceMetadata(this.mMetadataParcel.deviceMetadata);
+    }
+
+    /**
+     * Builder used to create FastPairAntispoofkeyDeviceMetadata.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final class Builder {
+
+        private final FastPairAntispoofKeyDeviceMetadataParcel mBuilderParcel;
+
+        /**
+         * Default constructor of Builder.
+         *
+         * @hide
+         */
+        @SystemApi
+        public Builder() {
+            mBuilderParcel = new FastPairAntispoofKeyDeviceMetadataParcel();
+            mBuilderParcel.antispoofPublicKey = null;
+            mBuilderParcel.deviceMetadata = null;
+        }
+
+        /**
+         * Set AntiSpoof public key, which uniquely identify a Fast Pair device type.
+         *
+         * @param antispoofPublicKey is 64 bytes, see <a href="https://developers.google.com/nearby/fast-pair/spec#data_format">Data Format</a>.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setAntispoofPublicKey(@Nullable byte[] antispoofPublicKey) {
+            mBuilderParcel.antispoofPublicKey = antispoofPublicKey;
+            return this;
+        }
+
+        /**
+         * Set Fast Pair metadata, which is the property of a Fast Pair device type, including
+         * device images and strings.
+         *
+         * @param metadata Fast Pair device meta data.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setFastPairDeviceMetadata(@Nullable FastPairDeviceMetadata metadata) {
+            if (metadata != null) {
+                mBuilderParcel.deviceMetadata = metadata.mMetadataParcel;
+            } else {
+                mBuilderParcel.deviceMetadata = null;
+            }
+            return this;
+        }
+
+        /**
+         * Build {@link FastPairAntispoofKeyDeviceMetadata} with the currently set configuration.
+         *
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public FastPairAntispoofKeyDeviceMetadata build() {
+            return new FastPairAntispoofKeyDeviceMetadata(mBuilderParcel);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairClient.java b/nearby/framework/java/android/nearby/FastPairClient.java
new file mode 100644
index 0000000..dc70d71
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairClient.java
@@ -0,0 +1,99 @@
+/*
+ * 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 android.annotation.BinderThread;
+import android.content.Context;
+import android.nearby.aidl.IFastPairClient;
+import android.nearby.aidl.IFastPairStatusCallback;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * 0p API for controlling Fast Pair. It communicates between main thread and service.
+ *
+ * @hide
+ */
+public class FastPairClient {
+
+    private static final String TAG = "FastPairClient";
+    private final IBinder mBinder;
+    private final WeakReference<Context> mWeakContext;
+    IFastPairClient mFastPairClient;
+    PairStatusCallbackIBinder mPairStatusCallbackIBinder;
+
+    /**
+     * The Ibinder instance should be from
+     * {@link com.android.server.nearby.fastpair.halfsheet.FastPairService} so that the client can
+     * talk with the service.
+     */
+    public FastPairClient(Context context, IBinder binder) {
+        mBinder = binder;
+        mFastPairClient = IFastPairClient.Stub.asInterface(mBinder);
+        mWeakContext = new WeakReference<>(context);
+    }
+
+    /**
+     * Registers a callback at service to get UI updates.
+     */
+    public void registerHalfSheet(FastPairStatusCallback fastPairStatusCallback) {
+        if (mPairStatusCallbackIBinder != null) {
+            return;
+        }
+        mPairStatusCallbackIBinder = new PairStatusCallbackIBinder(fastPairStatusCallback);
+        try {
+            mFastPairClient.registerHalfSheet(mPairStatusCallbackIBinder);
+        } catch (RemoteException e) {
+            Log.w(TAG, "Failed to register fastPairStatusCallback", e);
+        }
+    }
+
+    /**
+     * Pairs the device at service.
+     */
+    public void connect(FastPairDevice fastPairDevice) {
+        try {
+            mFastPairClient.connect(fastPairDevice);
+        } catch (RemoteException e) {
+            Log.w(TAG, "Failed to connect Fast Pair device" + fastPairDevice, e);
+        }
+    }
+
+    private class PairStatusCallbackIBinder extends IFastPairStatusCallback.Stub {
+        private final FastPairStatusCallback mStatusCallback;
+
+        private PairStatusCallbackIBinder(FastPairStatusCallback fastPairStatusCallback) {
+            mStatusCallback = fastPairStatusCallback;
+        }
+
+        @BinderThread
+        @Override
+        public synchronized void onPairUpdate(FastPairDevice fastPairDevice,
+                PairStatusMetadata pairStatusMetadata) {
+            Context context = mWeakContext.get();
+            if (context != null) {
+                Handler handler = new Handler(context.getMainLooper());
+                handler.post(() ->
+                        mStatusCallback.onPairUpdate(fastPairDevice, pairStatusMetadata));
+            }
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairDataProviderService.java b/nearby/framework/java/android/nearby/FastPairDataProviderService.java
new file mode 100644
index 0000000..c6a1a65
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairDataProviderService.java
@@ -0,0 +1,761 @@
+/*
+ * 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 android.nearby;
+
+import android.accounts.Account;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.app.Service;
+import android.content.Intent;
+import android.nearby.aidl.ByteArrayParcel;
+import android.nearby.aidl.FastPairAccountDevicesMetadataRequestParcel;
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataRequestParcel;
+import android.nearby.aidl.FastPairEligibleAccountParcel;
+import android.nearby.aidl.FastPairEligibleAccountsRequestParcel;
+import android.nearby.aidl.FastPairManageAccountDeviceRequestParcel;
+import android.nearby.aidl.FastPairManageAccountRequestParcel;
+import android.nearby.aidl.IFastPairAccountDevicesMetadataCallback;
+import android.nearby.aidl.IFastPairAntispoofKeyDeviceMetadataCallback;
+import android.nearby.aidl.IFastPairDataProvider;
+import android.nearby.aidl.IFastPairEligibleAccountsCallback;
+import android.nearby.aidl.IFastPairManageAccountCallback;
+import android.nearby.aidl.IFastPairManageAccountDeviceCallback;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A service class for fast pair data providers outside the system server.
+ *
+ * Fast pair providers should be wrapped in a non-exported service which returns the result of
+ * {@link #getBinder()} from the service's {@link android.app.Service#onBind(Intent)} method. The
+ * service should not be exported so that components other than the system server cannot bind to it.
+ * Alternatively, the service may be guarded by a permission that only system server can obtain.
+ *
+ * <p>Fast Pair providers are identified by their UID / package name.
+ *
+ * @hide
+ */
+@SystemApi
+public abstract class FastPairDataProviderService extends Service {
+    /**
+     * The action the wrapping service should have in its intent filter to implement the
+     * {@link android.nearby.FastPairDataProviderBase}.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final String ACTION_FAST_PAIR_DATA_PROVIDER =
+            "android.nearby.action.FAST_PAIR_DATA_PROVIDER";
+
+    /**
+     * Manage request type to add, or opt-in.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final int MANAGE_REQUEST_ADD = 0;
+
+    /**
+     * Manage request type to remove, or opt-out.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final int MANAGE_REQUEST_REMOVE = 1;
+
+    /**
+     * @hide
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(value = {
+            MANAGE_REQUEST_ADD,
+            MANAGE_REQUEST_REMOVE})
+    @interface ManageRequestType {}
+
+    /**
+     * Error code for bad request.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final int ERROR_CODE_BAD_REQUEST = 0;
+
+    /**
+     * Error code for internal error.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final int ERROR_CODE_INTERNAL_ERROR = 1;
+
+    /**
+     * @hide
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(value = {
+            ERROR_CODE_BAD_REQUEST,
+            ERROR_CODE_INTERNAL_ERROR})
+    @interface ErrorCode {}
+
+    private final IBinder mBinder;
+    private final String mTag;
+
+    /**
+     * Constructor of FastPairDataProviderService.
+     *
+     * @param tag TAG for on device logging.
+     * @hide
+     */
+    @SystemApi
+    public FastPairDataProviderService(@NonNull String tag) {
+        mBinder = new Service();
+        mTag = tag;
+    }
+
+    @Override
+    @NonNull
+    public final IBinder onBind(@NonNull Intent intent) {
+        return mBinder;
+    }
+
+    /**
+     * Callback to be invoked when an AntispoofKeyed device metadata is loaded.
+     *
+     * @hide
+     */
+    @SystemApi
+    public interface FastPairAntispoofKeyDeviceMetadataCallback {
+
+        /**
+         * Invoked once the meta data is loaded.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onFastPairAntispoofKeyDeviceMetadataReceived(
+                @NonNull FastPairAntispoofKeyDeviceMetadata metadata);
+
+        /** Invoked in case of error.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onError(@ErrorCode int code, @Nullable String message);
+    }
+
+    /**
+     * Callback to be invoked when Fast Pair devices of a given account is loaded.
+     *
+     * @hide
+     */
+    @SystemApi
+    public interface FastPairAccountDevicesMetadataCallback {
+
+        /**
+         * Should be invoked once the metadatas are loaded.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onFastPairAccountDevicesMetadataReceived(
+                @NonNull Collection<FastPairAccountKeyDeviceMetadata> metadatas);
+        /**
+         * Invoked in case of error.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onError(@ErrorCode int code, @Nullable String message);
+    }
+
+    /**
+     * Callback to be invoked when FastPair eligible accounts are loaded.
+     *
+     * @hide
+     */
+    @SystemApi
+    public interface FastPairEligibleAccountsCallback {
+
+        /**
+         * Should be invoked once the eligible accounts are loaded.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onFastPairEligibleAccountsReceived(
+                @NonNull Collection<FastPairEligibleAccount> accounts);
+        /**
+         * Invoked in case of error.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onError(@ErrorCode int code, @Nullable String message);
+    }
+
+    /**
+     * Callback to be invoked when a management action is finished.
+     *
+     * @hide
+     */
+    @SystemApi
+    public interface FastPairManageActionCallback {
+
+        /**
+         * Should be invoked once the manage action is successful.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onSuccess();
+        /**
+         * Invoked in case of error.
+         *
+         * @hide
+         */
+        @SystemApi
+        void onError(@ErrorCode int code, @Nullable String message);
+    }
+
+    /**
+     * Fulfills the Fast Pair device metadata request by using callback to send back the
+     * device meta data of a given modelId.
+     *
+     * @hide
+     */
+    @SystemApi
+    public abstract void onLoadFastPairAntispoofKeyDeviceMetadata(
+            @NonNull FastPairAntispoofKeyDeviceMetadataRequest request,
+            @NonNull FastPairAntispoofKeyDeviceMetadataCallback callback);
+
+    /**
+     * Fulfills the account tied Fast Pair devices metadata request by using callback to send back
+     * all Fast Pair device's metadata of a given account.
+     *
+     * @hide
+     */
+    @SystemApi
+    public abstract void onLoadFastPairAccountDevicesMetadata(
+            @NonNull FastPairAccountDevicesMetadataRequest request,
+            @NonNull FastPairAccountDevicesMetadataCallback callback);
+
+    /**
+     * Fulfills the Fast Pair eligible accounts request by using callback to send back Fast Pair
+     * eligible accounts.
+     *
+     * @hide
+     */
+    @SystemApi
+    public abstract void onLoadFastPairEligibleAccounts(
+            @NonNull FastPairEligibleAccountsRequest request,
+            @NonNull FastPairEligibleAccountsCallback callback);
+
+    /**
+     * Fulfills the Fast Pair account management request by using callback to send back result.
+     *
+     * @hide
+     */
+    @SystemApi
+    public abstract void onManageFastPairAccount(
+            @NonNull FastPairManageAccountRequest request,
+            @NonNull FastPairManageActionCallback callback);
+
+    /**
+     * Fulfills the request to manage device-account mapping by using callback to send back result.
+     *
+     * @hide
+     */
+    @SystemApi
+    public abstract void onManageFastPairAccountDevice(
+            @NonNull FastPairManageAccountDeviceRequest request,
+            @NonNull FastPairManageActionCallback callback);
+
+    /**
+     * Class for reading FastPairAntispoofKeyDeviceMetadataRequest, which specifies the model ID of
+     * a Fast Pair device. To fulfill this request, corresponding
+     * {@link FastPairAntispoofKeyDeviceMetadata} should be fetched and returned.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static class FastPairAntispoofKeyDeviceMetadataRequest {
+
+        private final FastPairAntispoofKeyDeviceMetadataRequestParcel mMetadataRequestParcel;
+
+        private FastPairAntispoofKeyDeviceMetadataRequest(
+                final FastPairAntispoofKeyDeviceMetadataRequestParcel metaDataRequestParcel) {
+            this.mMetadataRequestParcel = metaDataRequestParcel;
+        }
+
+        /**
+         * Get modelId (24 bit), the key for FastPairAntispoofKeyDeviceMetadata in the same format
+         * returned by Google at device registration time.
+         *
+         * ModelId format is defined at device registration time, see
+         * <a href="https://developers.google.com/nearby/fast-pair/spec#model_id">Model ID</a>.
+         * @return raw bytes of modelId in the same format returned by Google at device registration
+         *         time.
+         * @hide
+         */
+        @SystemApi
+        public @NonNull byte[] getModelId() {
+            return this.mMetadataRequestParcel.modelId;
+        }
+    }
+
+    /**
+     * Class for reading FastPairAccountDevicesMetadataRequest, which specifies the Fast Pair
+     * account and the allow list of the FastPair device keys saved to the account (i.e., FastPair
+     * accountKeys).
+     *
+     * A Fast Pair accountKey is created when a Fast Pair device is saved to an account. It is per
+     * Fast Pair device per account.
+     *
+     * To retrieve all Fast Pair accountKeys saved to an account, the caller needs to set
+     * account with an empty allow list.
+     *
+     * To retrieve metadata of a selected list of Fast Pair devices saved to an account, the caller
+     * needs to set account with a non-empty allow list.
+     * @hide
+     */
+    @SystemApi
+    public static class FastPairAccountDevicesMetadataRequest {
+
+        private final FastPairAccountDevicesMetadataRequestParcel mMetadataRequestParcel;
+
+        private FastPairAccountDevicesMetadataRequest(
+                final FastPairAccountDevicesMetadataRequestParcel metaDataRequestParcel) {
+            this.mMetadataRequestParcel = metaDataRequestParcel;
+        }
+
+        /**
+         * Get FastPair account, whose Fast Pair devices' metadata is requested.
+         *
+         * @return a FastPair account.
+         * @hide
+         */
+        @SystemApi
+        public @NonNull Account getAccount() {
+            return this.mMetadataRequestParcel.account;
+        }
+
+        /**
+         * Get allowlist of Fast Pair devices using a collection of deviceAccountKeys.
+         * Note that as a special case, empty list actually means all FastPair devices under the
+         * account instead of none.
+         *
+         * DeviceAccountKey is 16 bytes: first byte is 0x04. Other 15 bytes are randomly generated.
+         *
+         * @return allowlist of Fast Pair devices using a collection of deviceAccountKeys.
+         * @hide
+         */
+        @SystemApi
+        public @NonNull Collection<byte[]> getDeviceAccountKeys()  {
+            if (this.mMetadataRequestParcel.deviceAccountKeys == null) {
+                return new ArrayList<byte[]>(0);
+            }
+            List<byte[]> deviceAccountKeys =
+                    new ArrayList<>(this.mMetadataRequestParcel.deviceAccountKeys.length);
+            for (ByteArrayParcel deviceAccountKey : this.mMetadataRequestParcel.deviceAccountKeys) {
+                deviceAccountKeys.add(deviceAccountKey.byteArray);
+            }
+            return deviceAccountKeys;
+        }
+    }
+
+    /**
+     *  Class for reading FastPairEligibleAccountsRequest. Upon receiving this request, Fast Pair
+     *  eligible accounts should be returned to bind Fast Pair devices.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static class FastPairEligibleAccountsRequest {
+        @SuppressWarnings("UnusedVariable")
+        private final FastPairEligibleAccountsRequestParcel mAccountsRequestParcel;
+
+        private FastPairEligibleAccountsRequest(
+                final FastPairEligibleAccountsRequestParcel accountsRequestParcel) {
+            this.mAccountsRequestParcel = accountsRequestParcel;
+        }
+    }
+
+    /**
+     * Class for reading FastPairManageAccountRequest. If the request type is MANAGE_REQUEST_ADD,
+     * the account is enabled to bind Fast Pair devices; If the request type is
+     * MANAGE_REQUEST_REMOVE, the account is disabled to bind more Fast Pair devices. Furthermore,
+     * all existing bounded Fast Pair devices are unbounded.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static class FastPairManageAccountRequest {
+
+        private final FastPairManageAccountRequestParcel mAccountRequestParcel;
+
+        private FastPairManageAccountRequest(
+                final FastPairManageAccountRequestParcel accountRequestParcel) {
+            this.mAccountRequestParcel = accountRequestParcel;
+        }
+
+        /**
+         * Get request type: MANAGE_REQUEST_ADD, or MANAGE_REQUEST_REMOVE.
+         *
+         * @hide
+         */
+        @SystemApi
+        public @ManageRequestType int getRequestType() {
+            return this.mAccountRequestParcel.requestType;
+        }
+        /**
+         * Get account.
+         *
+         * @hide
+         */
+        @SystemApi
+        public @NonNull Account getAccount() {
+            return this.mAccountRequestParcel.account;
+        }
+    }
+
+    /**
+     *  Class for reading FastPairManageAccountDeviceRequest. If the request type is
+     *  MANAGE_REQUEST_ADD, then a Fast Pair device is bounded to a Fast Pair account. If the
+     *  request type is MANAGE_REQUEST_REMOVE, then a Fast Pair device is removed from a Fast Pair
+     *  account.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static class FastPairManageAccountDeviceRequest {
+
+        private final FastPairManageAccountDeviceRequestParcel mRequestParcel;
+
+        private FastPairManageAccountDeviceRequest(
+                final FastPairManageAccountDeviceRequestParcel requestParcel) {
+            this.mRequestParcel = requestParcel;
+        }
+
+        /**
+         * Get request type: MANAGE_REQUEST_ADD, or MANAGE_REQUEST_REMOVE.
+         *
+         * @hide
+         */
+        @SystemApi
+        public @ManageRequestType int getRequestType() {
+            return this.mRequestParcel.requestType;
+        }
+        /**
+         * Get account.
+         *
+         * @hide
+         */
+        @SystemApi
+        public @NonNull Account getAccount() {
+            return this.mRequestParcel.account;
+        }
+        /**
+         * Get BleAddress.
+         *
+         * @hide
+         */
+        @SystemApi
+        public @Nullable String getBleAddress() {
+            return this.mRequestParcel.bleAddress;
+        }
+        /**
+         * Get account key device metadata.
+         *
+         * @hide
+         */
+        @SystemApi
+        public @NonNull FastPairAccountKeyDeviceMetadata getAccountKeyDeviceMetadata() {
+            return new FastPairAccountKeyDeviceMetadata(
+                    this.mRequestParcel.accountKeyDeviceMetadata);
+        }
+    }
+
+    /**
+     * Callback class that sends back FastPairAntispoofKeyDeviceMetadata.
+     */
+    private final class WrapperFastPairAntispoofKeyDeviceMetadataCallback implements
+            FastPairAntispoofKeyDeviceMetadataCallback {
+
+        private IFastPairAntispoofKeyDeviceMetadataCallback mCallback;
+
+        private WrapperFastPairAntispoofKeyDeviceMetadataCallback(
+                IFastPairAntispoofKeyDeviceMetadataCallback callback) {
+            mCallback = callback;
+        }
+
+        /**
+         * Sends back FastPairAntispoofKeyDeviceMetadata.
+         */
+        @Override
+        public void onFastPairAntispoofKeyDeviceMetadataReceived(
+                @NonNull FastPairAntispoofKeyDeviceMetadata metadata) {
+            try {
+                mCallback.onFastPairAntispoofKeyDeviceMetadataReceived(metadata.mMetadataParcel);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+
+        @Override
+        public void onError(@ErrorCode int code, @Nullable String message) {
+            try {
+                mCallback.onError(code, message);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+    }
+
+    /**
+     * Callback class that sends back collection of FastPairAccountKeyDeviceMetadata.
+     */
+    private final class WrapperFastPairAccountDevicesMetadataCallback implements
+            FastPairAccountDevicesMetadataCallback {
+
+        private IFastPairAccountDevicesMetadataCallback mCallback;
+
+        private WrapperFastPairAccountDevicesMetadataCallback(
+                IFastPairAccountDevicesMetadataCallback callback) {
+            mCallback = callback;
+        }
+
+        /**
+         * Sends back collection of FastPairAccountKeyDeviceMetadata.
+         */
+        @Override
+        public void onFastPairAccountDevicesMetadataReceived(
+                @NonNull Collection<FastPairAccountKeyDeviceMetadata> metadatas) {
+            FastPairAccountKeyDeviceMetadataParcel[] metadataParcels =
+                    new FastPairAccountKeyDeviceMetadataParcel[metadatas.size()];
+            int i = 0;
+            for (FastPairAccountKeyDeviceMetadata metadata : metadatas) {
+                metadataParcels[i] = metadata.mMetadataParcel;
+                i = i + 1;
+            }
+            try {
+                mCallback.onFastPairAccountDevicesMetadataReceived(metadataParcels);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+
+        @Override
+        public void onError(@ErrorCode int code, @Nullable String message) {
+            try {
+                mCallback.onError(code, message);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+    }
+
+    /**
+     * Callback class that sends back eligible Fast Pair accounts.
+     */
+    private final class WrapperFastPairEligibleAccountsCallback implements
+            FastPairEligibleAccountsCallback {
+
+        private IFastPairEligibleAccountsCallback mCallback;
+
+        private WrapperFastPairEligibleAccountsCallback(
+                IFastPairEligibleAccountsCallback callback) {
+            mCallback = callback;
+        }
+
+        /**
+         * Sends back the eligible Fast Pair accounts.
+         */
+        @Override
+        public void onFastPairEligibleAccountsReceived(
+                @NonNull Collection<FastPairEligibleAccount> accounts) {
+            int i = 0;
+            FastPairEligibleAccountParcel[] accountParcels =
+                    new FastPairEligibleAccountParcel[accounts.size()];
+            for (FastPairEligibleAccount account: accounts) {
+                accountParcels[i] = account.mAccountParcel;
+                i = i + 1;
+            }
+            try {
+                mCallback.onFastPairEligibleAccountsReceived(accountParcels);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+
+        @Override
+        public void onError(@ErrorCode int code, @Nullable String message) {
+            try {
+                mCallback.onError(code, message);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+    }
+
+    /**
+     * Callback class that sends back Fast Pair account management result.
+     */
+    private final class WrapperFastPairManageAccountCallback implements
+            FastPairManageActionCallback {
+
+        private IFastPairManageAccountCallback mCallback;
+
+        private WrapperFastPairManageAccountCallback(
+                IFastPairManageAccountCallback callback) {
+            mCallback = callback;
+        }
+
+        /**
+         * Sends back Fast Pair account opt in result.
+         */
+        @Override
+        public void onSuccess() {
+            try {
+                mCallback.onSuccess();
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+
+        @Override
+        public void onError(@ErrorCode int code, @Nullable String message) {
+            try {
+                mCallback.onError(code, message);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+    }
+
+    /**
+     * Call back class that sends back account-device mapping management result.
+     */
+    private final class WrapperFastPairManageAccountDeviceCallback implements
+            FastPairManageActionCallback {
+
+        private IFastPairManageAccountDeviceCallback mCallback;
+
+        private WrapperFastPairManageAccountDeviceCallback(
+                IFastPairManageAccountDeviceCallback callback) {
+            mCallback = callback;
+        }
+
+        /**
+         * Sends back the account-device mapping management result.
+         */
+        @Override
+        public void onSuccess() {
+            try {
+                mCallback.onSuccess();
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+
+        @Override
+        public void onError(@ErrorCode int code, @Nullable String message) {
+            try {
+                mCallback.onError(code, message);
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            } catch (RuntimeException e) {
+                Log.w(mTag, e);
+            }
+        }
+    }
+
+    private final class Service extends IFastPairDataProvider.Stub {
+
+        Service() {
+        }
+
+        @Override
+        public void loadFastPairAntispoofKeyDeviceMetadata(
+                @NonNull FastPairAntispoofKeyDeviceMetadataRequestParcel requestParcel,
+                IFastPairAntispoofKeyDeviceMetadataCallback callback) {
+            onLoadFastPairAntispoofKeyDeviceMetadata(
+                    new FastPairAntispoofKeyDeviceMetadataRequest(requestParcel),
+                    new WrapperFastPairAntispoofKeyDeviceMetadataCallback(callback));
+        }
+
+        @Override
+        public void loadFastPairAccountDevicesMetadata(
+                @NonNull FastPairAccountDevicesMetadataRequestParcel requestParcel,
+                IFastPairAccountDevicesMetadataCallback callback) {
+            onLoadFastPairAccountDevicesMetadata(
+                    new FastPairAccountDevicesMetadataRequest(requestParcel),
+                    new WrapperFastPairAccountDevicesMetadataCallback(callback));
+        }
+
+        @Override
+        public void loadFastPairEligibleAccounts(
+                @NonNull FastPairEligibleAccountsRequestParcel requestParcel,
+                IFastPairEligibleAccountsCallback callback) {
+            onLoadFastPairEligibleAccounts(new FastPairEligibleAccountsRequest(requestParcel),
+                    new WrapperFastPairEligibleAccountsCallback(callback));
+        }
+
+        @Override
+        public void manageFastPairAccount(
+                @NonNull FastPairManageAccountRequestParcel requestParcel,
+                IFastPairManageAccountCallback callback) {
+            onManageFastPairAccount(new FastPairManageAccountRequest(requestParcel),
+                    new WrapperFastPairManageAccountCallback(callback));
+        }
+
+        @Override
+        public void manageFastPairAccountDevice(
+                @NonNull FastPairManageAccountDeviceRequestParcel requestParcel,
+                IFastPairManageAccountDeviceCallback callback) {
+            onManageFastPairAccountDevice(new FastPairManageAccountDeviceRequest(requestParcel),
+                    new WrapperFastPairManageAccountDeviceCallback(callback));
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairDevice.aidl b/nearby/framework/java/android/nearby/FastPairDevice.aidl
new file mode 100644
index 0000000..5942966
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairDevice.aidl
@@ -0,0 +1,24 @@
+/*
+ * 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;
+
+/**
+ * A class represents a Fast Pair device that can be discovered by multiple mediums.
+ *
+ * {@hide}
+ */
+parcelable FastPairDevice;
diff --git a/nearby/framework/java/android/nearby/FastPairDevice.java b/nearby/framework/java/android/nearby/FastPairDevice.java
new file mode 100644
index 0000000..7160533
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairDevice.java
@@ -0,0 +1,332 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A class represents a Fast Pair device that can be discovered by multiple mediums.
+ *
+ * @hide
+ */
+public class FastPairDevice extends NearbyDevice implements Parcelable {
+    /**
+     * Used to read a FastPairDevice from a Parcel.
+     */
+    public static final Creator<FastPairDevice> CREATOR = new Creator<FastPairDevice>() {
+        @Override
+        public FastPairDevice createFromParcel(Parcel in) {
+            FastPairDevice.Builder builder = new FastPairDevice.Builder();
+            if (in.readInt() == 1) {
+                builder.setName(in.readString());
+            }
+            int size = in.readInt();
+            for (int i = 0; i < size; i++) {
+                builder.addMedium(in.readInt());
+            }
+            builder.setRssi(in.readInt());
+            builder.setTxPower(in.readInt());
+            if (in.readInt() == 1) {
+                builder.setModelId(in.readString());
+            }
+            builder.setBluetoothAddress(in.readString());
+            if (in.readInt() == 1) {
+                int dataLength = in.readInt();
+                byte[] data = new byte[dataLength];
+                in.readByteArray(data);
+                builder.setData(data);
+            }
+            return builder.build();
+        }
+
+        @Override
+        public FastPairDevice[] newArray(int size) {
+            return new FastPairDevice[size];
+        }
+    };
+
+    // The transmit power in dBm. Valid range is [-127, 126]. a
+    // See android.bluetooth.le.ScanResult#getTxPower
+    private int mTxPower;
+
+    // Some OEM devices devices don't have model Id.
+    @Nullable private final String mModelId;
+
+    // Bluetooth hardware address as string. Can be read from BLE ScanResult.
+    private final String mBluetoothAddress;
+
+    @Nullable
+    private final byte[] mData;
+
+    /**
+     * Creates a new FastPairDevice.
+     *
+     * @param name Name of the FastPairDevice. Can be {@code null} if there is no name.
+     * @param mediums The {@link Medium}s over which the device is discovered.
+     * @param rssi The received signal strength in dBm.
+     * @param txPower The transmit power in dBm. Valid range is [-127, 126].
+     * @param modelId The identifier of the Fast Pair device.
+     *                Can be {@code null} if there is no Model ID.
+     * @param bluetoothAddress The hardware address of this BluetoothDevice.
+     * @param data Extra data for a Fast Pair device.
+     */
+    public FastPairDevice(@Nullable String name,
+            List<Integer> mediums,
+            int rssi,
+            int txPower,
+            @Nullable String modelId,
+            @NonNull String bluetoothAddress,
+            @Nullable byte[] data) {
+        super(name, mediums, rssi);
+        this.mTxPower = txPower;
+        this.mModelId = modelId;
+        this.mBluetoothAddress = bluetoothAddress;
+        this.mData = data;
+    }
+
+    /**
+     * Gets the transmit power in dBm. A value of
+     * android.bluetooth.le.ScanResult#TX_POWER_NOT_PRESENT
+     * indicates that the TX power is not present.
+     */
+    @IntRange(from = -127, to = 126)
+    public int getTxPower() {
+        return mTxPower;
+    }
+
+    /**
+     * Gets the identifier of the Fast Pair device. Can be {@code null} if there is no Model ID.
+     */
+    @Nullable
+    public String getModelId() {
+        return this.mModelId;
+    }
+
+    /**
+     * Gets the hardware address of this BluetoothDevice.
+     */
+    @NonNull
+    public String getBluetoothAddress() {
+        return mBluetoothAddress;
+    }
+
+    /**
+     * Gets the extra data for a Fast Pair device. Can be {@code null} if there is extra data.
+     *
+     * @hide
+     */
+    @Nullable
+    public byte[] getData() {
+        return mData;
+    }
+
+    /**
+     * No special parcel contents.
+     */
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    /**
+     * Returns a string representation of this FastPairDevice.
+     */
+    @Override
+    public String toString() {
+        StringBuilder stringBuilder = new StringBuilder();
+        stringBuilder.append("FastPairDevice [");
+        String name = getName();
+        if (getName() != null && !name.isEmpty()) {
+            stringBuilder.append("name=").append(name).append(", ");
+        }
+        stringBuilder.append("medium={");
+        for (int medium: getMediums()) {
+            stringBuilder.append(mediumToString(medium));
+        }
+        stringBuilder.append("} rssi=").append(getRssi());
+        stringBuilder.append(" txPower=").append(mTxPower);
+        stringBuilder.append(" modelId=").append(mModelId);
+        stringBuilder.append(" bluetoothAddress=").append(mBluetoothAddress);
+        stringBuilder.append("]");
+        return stringBuilder.toString();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof FastPairDevice) {
+            FastPairDevice otherDevice = (FastPairDevice) other;
+            if (!super.equals(other)) {
+                return false;
+            }
+            return  mTxPower == otherDevice.mTxPower
+                    && Objects.equals(mModelId, otherDevice.mModelId)
+                    && Objects.equals(mBluetoothAddress, otherDevice.mBluetoothAddress)
+                    && Arrays.equals(mData, otherDevice.mData);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(
+                getName(), getMediums(), getRssi(), mTxPower, mModelId, mBluetoothAddress,
+                Arrays.hashCode(mData));
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        String name = getName();
+        dest.writeInt(name == null ? 0 : 1);
+        if (name != null) {
+            dest.writeString(name);
+        }
+        List<Integer> mediums = getMediums();
+        dest.writeInt(mediums.size());
+        for (int medium : mediums) {
+            dest.writeInt(medium);
+        }
+        dest.writeInt(getRssi());
+        dest.writeInt(mTxPower);
+        dest.writeInt(mModelId == null ? 0 : 1);
+        if (mModelId != null) {
+            dest.writeString(mModelId);
+        }
+        dest.writeString(mBluetoothAddress);
+        dest.writeInt(mData == null ? 0 : 1);
+        if (mData != null) {
+            dest.writeInt(mData.length);
+            dest.writeByteArray(mData);
+        }
+    }
+
+    /**
+     * A builder class for {@link FastPairDevice}
+     *
+     * @hide
+     */
+    public static final class Builder {
+        private final List<Integer> mMediums;
+
+        @Nullable private String mName;
+        private int mRssi;
+        private int mTxPower;
+        @Nullable private String mModelId;
+        private String mBluetoothAddress;
+        @Nullable private byte[] mData;
+
+        public Builder() {
+            mMediums = new ArrayList<>();
+        }
+
+        /**
+         * Sets the name of the Fast Pair device.
+         *
+         * @param name Name of the FastPairDevice. Can be {@code null} if there is no name.
+         */
+        @NonNull
+        public Builder setName(@Nullable String name) {
+            mName = name;
+            return this;
+        }
+
+        /**
+         * Sets the medium over which the Fast Pair device is discovered.
+         *
+         * @param medium The {@link Medium} over which the device is discovered.
+         */
+        @NonNull
+        public Builder addMedium(@Medium int medium) {
+            mMediums.add(medium);
+            return this;
+        }
+
+        /**
+         * Sets the RSSI between the scan device and the discovered Fast Pair device.
+         *
+         * @param rssi The received signal strength in dBm.
+         */
+        @NonNull
+        public Builder setRssi(@IntRange(from = -127, to = 126) int rssi) {
+            mRssi = rssi;
+            return this;
+        }
+
+        /**
+         * Sets the txPower.
+         *
+         * @param txPower The transmit power in dBm
+         */
+        @NonNull
+        public Builder setTxPower(@IntRange(from = -127, to = 126) int txPower) {
+            mTxPower = txPower;
+            return this;
+        }
+
+        /**
+         * Sets the model Id of this Fast Pair device.
+         *
+         * @param modelId The identifier of the Fast Pair device. Can be {@code null}
+         *                if there is no Model ID.
+         */
+        @NonNull
+        public Builder setModelId(@Nullable String modelId) {
+            mModelId = modelId;
+            return this;
+        }
+
+        /**
+         * Sets the hardware address of this BluetoothDevice.
+         *
+         * @param bluetoothAddress The hardware address of this BluetoothDevice.
+         */
+        @NonNull
+        public Builder setBluetoothAddress(@NonNull String bluetoothAddress) {
+            Objects.requireNonNull(bluetoothAddress);
+            mBluetoothAddress = bluetoothAddress;
+            return this;
+        }
+
+        /**
+         * Sets the raw data for a FastPairDevice. Can be {@code null} if there is no extra data.
+         *
+         * @hide
+         */
+        @NonNull
+        public Builder setData(@Nullable byte[] data) {
+            mData = data;
+            return this;
+        }
+
+        /**
+         * Builds a FastPairDevice and return it.
+         */
+        @NonNull
+        public FastPairDevice build() {
+            return new FastPairDevice(mName, mMediums, mRssi, mTxPower, mModelId,
+                    mBluetoothAddress, mData);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairDeviceMetadata.java b/nearby/framework/java/android/nearby/FastPairDeviceMetadata.java
new file mode 100644
index 0000000..ea75271
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairDeviceMetadata.java
@@ -0,0 +1,1009 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.nearby.aidl.FastPairDeviceMetadataParcel;
+
+/**
+ * Class for the properties of a given type of Fast Pair device, including images and text.
+ *
+ * @hide
+ */
+@SystemApi
+public class FastPairDeviceMetadata {
+
+    FastPairDeviceMetadataParcel mMetadataParcel;
+
+    FastPairDeviceMetadata(
+            FastPairDeviceMetadataParcel metadataParcel) {
+        this.mMetadataParcel = metadataParcel;
+    }
+
+    /**
+     * Get ImageUrl, which will be displayed in notification.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getImageUrl() {
+        return mMetadataParcel.imageUrl;
+    }
+
+    /**
+     * Get IntentUri, which will be launched to install companion app.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getIntentUri() {
+        return mMetadataParcel.intentUri;
+    }
+
+    /**
+     * Get BLE transmit power, as described in Fast Pair spec, see
+     * <a href="https://developers.google.com/nearby/fast-pair/spec#transmit_power">Transmit Power</a>
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getBleTxPower() {
+        return mMetadataParcel.bleTxPower;
+    }
+
+    /**
+     * Get Fast Pair Half Sheet trigger distance in meters.
+     *
+     * @hide
+     */
+    @SystemApi
+    public float getTriggerDistance() {
+        return mMetadataParcel.triggerDistance;
+    }
+
+    /**
+     * Get Fast Pair device image, which is submitted at device registration time to display on
+     * notification. It is a 32-bit PNG with dimensions of 512px by 512px.
+     *
+     * @return Fast Pair device image in 32-bit PNG with dimensions of 512px by 512px.
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public byte[] getImage() {
+        return mMetadataParcel.image;
+    }
+
+    /**
+     * Get Fast Pair device type.
+     * DEVICE_TYPE_UNSPECIFIED = 0;
+     * HEADPHONES = 1;
+     * TRUE_WIRELESS_HEADPHONES = 7;
+     * @hide
+     */
+    @SystemApi
+    public int getDeviceType() {
+        return mMetadataParcel.deviceType;
+    }
+
+    /**
+     * Get Fast Pair device name. e.g., "Pixel Buds A-Series".
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getName() {
+        return mMetadataParcel.name;
+    }
+
+    /**
+     * Get true wireless image url for left bud.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getTrueWirelessImageUrlLeftBud() {
+        return mMetadataParcel.trueWirelessImageUrlLeftBud;
+    }
+
+    /**
+     * Get true wireless image url for right bud.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getTrueWirelessImageUrlRightBud() {
+        return mMetadataParcel.trueWirelessImageUrlRightBud;
+    }
+
+    /**
+     * Get true wireless image url for case.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getTrueWirelessImageUrlCase() {
+        return mMetadataParcel.trueWirelessImageUrlCase;
+    }
+
+    /**
+     * Get Locale of the device.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getLocale() {
+        return mMetadataParcel.locale;
+    }
+
+    /**
+     * Get InitialNotificationDescription, which is a translated string of
+     * "Tap to pair. Earbuds will be tied to %s" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getInitialNotificationDescription() {
+        return mMetadataParcel.initialNotificationDescription;
+    }
+
+    /**
+     * Get InitialNotificationDescriptionNoAccount, which is a translated string of
+     * "Tap to pair with this device" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getInitialNotificationDescriptionNoAccount() {
+        return mMetadataParcel.initialNotificationDescriptionNoAccount;
+    }
+
+    /**
+     * Get OpenCompanionAppDescription, which is a translated string of
+     * "Tap to finish setup" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getOpenCompanionAppDescription() {
+        return mMetadataParcel.openCompanionAppDescription;
+    }
+
+    /**
+     * Get UpdateCompanionAppDescription, which is a translated string of
+     * "Tap to update device settings and finish setup" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getUpdateCompanionAppDescription() {
+        return mMetadataParcel.updateCompanionAppDescription;
+    }
+
+    /**
+     * Get DownloadCompanionAppDescription, which is a translated string of
+     * "Tap to download device app on Google Play and see all features" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getDownloadCompanionAppDescription() {
+        return mMetadataParcel.downloadCompanionAppDescription;
+    }
+
+    /**
+     * Get UnableToConnectTitle, which is a translated string of
+     * "Unable to connect" based on locale.
+     */
+    @Nullable
+    public String getUnableToConnectTitle() {
+        return mMetadataParcel.unableToConnectTitle;
+    }
+
+    /**
+     * Get UnableToConnectDescription, which is a translated string of
+     * "Try manually pairing to the device" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getUnableToConnectDescription() {
+        return mMetadataParcel.unableToConnectDescription;
+    }
+
+    /**
+     * Get InitialPairingDescription, which is a translated string of
+     * "%s will appear on devices linked with %s" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getInitialPairingDescription() {
+        return mMetadataParcel.initialPairingDescription;
+    }
+
+    /**
+     * Get ConnectSuccessCompanionAppInstalled, which is a translated string of
+     * "Your device is ready to be set up" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getConnectSuccessCompanionAppInstalled() {
+        return mMetadataParcel.connectSuccessCompanionAppInstalled;
+    }
+
+    /**
+     * Get ConnectSuccessCompanionAppNotInstalled, which is a translated string of
+     * "Download the device app on Google Play to see all available features" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getConnectSuccessCompanionAppNotInstalled() {
+        return mMetadataParcel.connectSuccessCompanionAppNotInstalled;
+    }
+
+    /**
+     * Get SubsequentPairingDescription, which is a translated string of
+     * "Connect %s to this phone" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getSubsequentPairingDescription() {
+        return mMetadataParcel.subsequentPairingDescription;
+    }
+
+    /**
+     * Get RetroactivePairingDescription, which is a translated string of
+     * "Save device to %s for faster pairing to your other devices" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getRetroactivePairingDescription() {
+        return mMetadataParcel.retroactivePairingDescription;
+    }
+
+    /**
+     * Get WaitLaunchCompanionAppDescription, which is a translated string of
+     * "This will take a few moments" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getWaitLaunchCompanionAppDescription() {
+        return mMetadataParcel.waitLaunchCompanionAppDescription;
+    }
+
+    /**
+     * Get FailConnectGoToSettingsDescription, which is a translated string of
+     * "Try manually pairing to the device by going to Settings" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getFailConnectGoToSettingsDescription() {
+        return mMetadataParcel.failConnectGoToSettingsDescription;
+    }
+
+    /**
+     * Get ConfirmPinTitle, which is a translated string of
+     * based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getConfirmPinTitle() {
+        return mMetadataParcel.confirmPinTitle;
+    }
+
+    /**
+     * Get ConfirmPinDescription, which is a translated string of "confirm pin" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getConfirmPinDescription() {
+        return mMetadataParcel.confirmPinDescription;
+    }
+
+    /**
+     * Get SyncContactsTitle, which is a translated string of "sync contacts title" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getSyncContactsTitle() {
+        return mMetadataParcel.syncContactsTitle;
+    }
+
+    /**
+     * Get SyncContactsDescription, which is a translated string of "sync contacts description"
+     * based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getSyncContactsDescription() {
+        return mMetadataParcel.syncContactsDescription;
+    }
+
+    /**
+     * Get SyncSmsTitle, which is a translated string of "sync sms title" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getSyncSmsTitle() {
+        return mMetadataParcel.syncSmsTitle;
+    }
+
+    /**
+     * Get SyncSmsDescription, which is a translated string of "sync sms description" based on
+     * locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getSyncSmsDescription() {
+        return mMetadataParcel.syncSmsDescription;
+    }
+
+    /**
+     * Get AssistantSetupHalfSheet, which is a translated string of
+     * "Tap to set up your Google Assistant" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getAssistantSetupHalfSheet() {
+        return mMetadataParcel.assistantSetupHalfSheet;
+    }
+
+    /**
+     * Get AssistantSetupNotification, which is a translated string of
+     * "Tap to set up your Google Assistant" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getAssistantSetupNotification() {
+        return mMetadataParcel.assistantSetupNotification;
+    }
+
+    /**
+     * Get FastPairTvConnectDeviceNoAccountDescription, which is a translated string of
+     * "Select connect to pair your %s with this device" based on locale.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getFastPairTvConnectDeviceNoAccountDescription() {
+        return mMetadataParcel.fastPairTvConnectDeviceNoAccountDescription;
+    }
+
+    /**
+     * Builder used to create FastPairDeviceMetadata.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final class Builder {
+
+        private final FastPairDeviceMetadataParcel mBuilderParcel;
+
+        /**
+         * Default constructor of Builder.
+         *
+         * @hide
+         */
+        @SystemApi
+        public Builder() {
+            mBuilderParcel = new FastPairDeviceMetadataParcel();
+            mBuilderParcel.imageUrl = null;
+            mBuilderParcel.intentUri = null;
+            mBuilderParcel.name = null;
+            mBuilderParcel.bleTxPower = 0;
+            mBuilderParcel.triggerDistance = 0;
+            mBuilderParcel.image = null;
+            mBuilderParcel.deviceType = 0;  // DEVICE_TYPE_UNSPECIFIED
+            mBuilderParcel.trueWirelessImageUrlLeftBud = null;
+            mBuilderParcel.trueWirelessImageUrlRightBud = null;
+            mBuilderParcel.trueWirelessImageUrlCase = null;
+            mBuilderParcel.locale = null;
+            mBuilderParcel.initialNotificationDescription = null;
+            mBuilderParcel.initialNotificationDescriptionNoAccount = null;
+            mBuilderParcel.openCompanionAppDescription = null;
+            mBuilderParcel.updateCompanionAppDescription = null;
+            mBuilderParcel.downloadCompanionAppDescription = null;
+            mBuilderParcel.unableToConnectTitle = null;
+            mBuilderParcel.unableToConnectDescription = null;
+            mBuilderParcel.initialPairingDescription = null;
+            mBuilderParcel.connectSuccessCompanionAppInstalled = null;
+            mBuilderParcel.connectSuccessCompanionAppNotInstalled = null;
+            mBuilderParcel.subsequentPairingDescription = null;
+            mBuilderParcel.retroactivePairingDescription = null;
+            mBuilderParcel.waitLaunchCompanionAppDescription = null;
+            mBuilderParcel.failConnectGoToSettingsDescription = null;
+            mBuilderParcel.confirmPinTitle = null;
+            mBuilderParcel.confirmPinDescription = null;
+            mBuilderParcel.syncContactsTitle = null;
+            mBuilderParcel.syncContactsDescription = null;
+            mBuilderParcel.syncSmsTitle = null;
+            mBuilderParcel.syncSmsDescription = null;
+            mBuilderParcel.assistantSetupHalfSheet = null;
+            mBuilderParcel.assistantSetupNotification = null;
+            mBuilderParcel.fastPairTvConnectDeviceNoAccountDescription = null;
+        }
+
+        /**
+         * Set ImageUlr.
+         *
+         * @param imageUrl Image Ulr.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setImageUrl(@Nullable String imageUrl) {
+            mBuilderParcel.imageUrl = imageUrl;
+            return this;
+        }
+
+        /**
+         * Set IntentUri.
+         *
+         * @param intentUri Intent uri.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setIntentUri(@Nullable String intentUri) {
+            mBuilderParcel.intentUri = intentUri;
+            return this;
+        }
+
+        /**
+         * Set device name.
+         *
+         * @param name Device name.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setName(@Nullable String name) {
+            mBuilderParcel.name = name;
+            return this;
+        }
+
+        /**
+         * Set ble transmission power.
+         *
+         * @param bleTxPower Ble transmission power.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setBleTxPower(int bleTxPower) {
+            mBuilderParcel.bleTxPower = bleTxPower;
+            return this;
+        }
+
+        /**
+         * Set trigger distance.
+         *
+         * @param triggerDistance Fast Pair trigger distance.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setTriggerDistance(float triggerDistance) {
+            mBuilderParcel.triggerDistance = triggerDistance;
+            return this;
+        }
+
+        /**
+         * Set image.
+         *
+         * @param image Fast Pair device image, which is submitted at device registration time to
+         *              display on notification. It is a 32-bit PNG with dimensions of
+         *              512px by 512px.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setImage(@Nullable byte[] image) {
+            mBuilderParcel.image = image;
+            return this;
+        }
+
+        /**
+         * Set device type.
+         *
+         * @param deviceType Fast Pair device type.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDeviceType(int deviceType) {
+            mBuilderParcel.deviceType = deviceType;
+            return this;
+        }
+
+        /**
+         * Set true wireless image url for left bud.
+         *
+         * @param trueWirelessImageUrlLeftBud True wireless image url for left bud.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setTrueWirelessImageUrlLeftBud(
+                @Nullable String trueWirelessImageUrlLeftBud) {
+            mBuilderParcel.trueWirelessImageUrlLeftBud = trueWirelessImageUrlLeftBud;
+            return this;
+        }
+
+        /**
+         * Set true wireless image url for right bud.
+         *
+         * @param trueWirelessImageUrlRightBud True wireless image url for right bud.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setTrueWirelessImageUrlRightBud(
+                @Nullable String trueWirelessImageUrlRightBud) {
+            mBuilderParcel.trueWirelessImageUrlRightBud = trueWirelessImageUrlRightBud;
+            return this;
+        }
+
+        /**
+         * Set true wireless image url for case.
+         *
+         * @param trueWirelessImageUrlCase True wireless image url for case.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setTrueWirelessImageUrlCase(@Nullable String trueWirelessImageUrlCase) {
+            mBuilderParcel.trueWirelessImageUrlCase = trueWirelessImageUrlCase;
+            return this;
+        }
+
+        /**
+         * Set Locale.
+         *
+         * @param locale Device locale.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setLocale(@Nullable String locale) {
+            mBuilderParcel.locale = locale;
+            return this;
+        }
+
+        /**
+         * Set InitialNotificationDescription.
+         *
+         * @param initialNotificationDescription Initial notification description.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setInitialNotificationDescription(
+                @Nullable String initialNotificationDescription) {
+            mBuilderParcel.initialNotificationDescription = initialNotificationDescription;
+            return this;
+        }
+
+        /**
+         * Set InitialNotificationDescriptionNoAccount.
+         *
+         * @param initialNotificationDescriptionNoAccount Initial notification description when
+         *                                                account is not present.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setInitialNotificationDescriptionNoAccount(
+                @Nullable String initialNotificationDescriptionNoAccount) {
+            mBuilderParcel.initialNotificationDescriptionNoAccount =
+                    initialNotificationDescriptionNoAccount;
+            return this;
+        }
+
+        /**
+         * Set OpenCompanionAppDescription.
+         *
+         * @param openCompanionAppDescription Description for opening companion app.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setOpenCompanionAppDescription(
+                @Nullable String openCompanionAppDescription) {
+            mBuilderParcel.openCompanionAppDescription = openCompanionAppDescription;
+            return this;
+        }
+
+        /**
+         * Set UpdateCompanionAppDescription.
+         *
+         * @param updateCompanionAppDescription Description for updating companion app.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setUpdateCompanionAppDescription(
+                @Nullable String updateCompanionAppDescription) {
+            mBuilderParcel.updateCompanionAppDescription = updateCompanionAppDescription;
+            return this;
+        }
+
+        /**
+         * Set DownloadCompanionAppDescription.
+         *
+         * @param downloadCompanionAppDescription Description for downloading companion app.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDownloadCompanionAppDescription(
+                @Nullable String downloadCompanionAppDescription) {
+            mBuilderParcel.downloadCompanionAppDescription = downloadCompanionAppDescription;
+            return this;
+        }
+
+        /**
+         * Set UnableToConnectTitle.
+         *
+         * @param unableToConnectTitle Title when Fast Pair device is unable to be connected to.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setUnableToConnectTitle(@Nullable String unableToConnectTitle) {
+            mBuilderParcel.unableToConnectTitle = unableToConnectTitle;
+            return this;
+        }
+
+        /**
+         * Set UnableToConnectDescription.
+         *
+         * @param unableToConnectDescription Description when Fast Pair device is unable to be
+         *                                   connected to.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setUnableToConnectDescription(
+                @Nullable String unableToConnectDescription) {
+            mBuilderParcel.unableToConnectDescription = unableToConnectDescription;
+            return this;
+        }
+
+        /**
+         * Set InitialPairingDescription.
+         *
+         * @param initialPairingDescription Description for Fast Pair initial pairing.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setInitialPairingDescription(@Nullable String initialPairingDescription) {
+            mBuilderParcel.initialPairingDescription = initialPairingDescription;
+            return this;
+        }
+
+        /**
+         * Set ConnectSuccessCompanionAppInstalled.
+         *
+         * @param connectSuccessCompanionAppInstalled Description that let user open the companion
+         *                                            app.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setConnectSuccessCompanionAppInstalled(
+                @Nullable String connectSuccessCompanionAppInstalled) {
+            mBuilderParcel.connectSuccessCompanionAppInstalled =
+                    connectSuccessCompanionAppInstalled;
+            return this;
+        }
+
+        /**
+         * Set ConnectSuccessCompanionAppNotInstalled.
+         *
+         * @param connectSuccessCompanionAppNotInstalled Description that let user download the
+         *                                               companion app.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setConnectSuccessCompanionAppNotInstalled(
+                @Nullable String connectSuccessCompanionAppNotInstalled) {
+            mBuilderParcel.connectSuccessCompanionAppNotInstalled =
+                    connectSuccessCompanionAppNotInstalled;
+            return this;
+        }
+
+        /**
+         * Set SubsequentPairingDescription.
+         *
+         * @param subsequentPairingDescription Description that reminds user there is a paired
+         *                                     device nearby.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setSubsequentPairingDescription(
+                @Nullable String subsequentPairingDescription) {
+            mBuilderParcel.subsequentPairingDescription = subsequentPairingDescription;
+            return this;
+        }
+
+        /**
+         * Set RetroactivePairingDescription.
+         *
+         * @param retroactivePairingDescription Description that reminds users opt in their device.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setRetroactivePairingDescription(
+                @Nullable String retroactivePairingDescription) {
+            mBuilderParcel.retroactivePairingDescription = retroactivePairingDescription;
+            return this;
+        }
+
+        /**
+         * Set WaitLaunchCompanionAppDescription.
+         *
+         * @param waitLaunchCompanionAppDescription Description that indicates companion app is
+         *                                          about to launch.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setWaitLaunchCompanionAppDescription(
+                @Nullable String waitLaunchCompanionAppDescription) {
+            mBuilderParcel.waitLaunchCompanionAppDescription =
+                    waitLaunchCompanionAppDescription;
+            return this;
+        }
+
+        /**
+         * Set FailConnectGoToSettingsDescription.
+         *
+         * @param failConnectGoToSettingsDescription Description that indicates go to bluetooth
+         *                                           settings when connection fail.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setFailConnectGoToSettingsDescription(
+                @Nullable String failConnectGoToSettingsDescription) {
+            mBuilderParcel.failConnectGoToSettingsDescription =
+                    failConnectGoToSettingsDescription;
+            return this;
+        }
+
+        /**
+         * Set ConfirmPinTitle.
+         *
+         * @param confirmPinTitle Title of the UI to ask the user to confirm the pin code.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setConfirmPinTitle(@Nullable String confirmPinTitle) {
+            mBuilderParcel.confirmPinTitle = confirmPinTitle;
+            return this;
+        }
+
+        /**
+         * Set ConfirmPinDescription.
+         *
+         * @param confirmPinDescription Description of the UI to ask the user to confirm the pin
+         *                              code.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setConfirmPinDescription(@Nullable String confirmPinDescription) {
+            mBuilderParcel.confirmPinDescription = confirmPinDescription;
+            return this;
+        }
+
+        /**
+         * Set SyncContactsTitle.
+         *
+         * @param syncContactsTitle Title of the UI to ask the user to confirm to sync contacts.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setSyncContactsTitle(@Nullable String syncContactsTitle) {
+            mBuilderParcel.syncContactsTitle = syncContactsTitle;
+            return this;
+        }
+
+        /**
+         * Set SyncContactsDescription.
+         *
+         * @param syncContactsDescription Description of the UI to ask the user to confirm to sync
+         *                                contacts.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setSyncContactsDescription(@Nullable String syncContactsDescription) {
+            mBuilderParcel.syncContactsDescription = syncContactsDescription;
+            return this;
+        }
+
+        /**
+         * Set SyncSmsTitle.
+         *
+         * @param syncSmsTitle Title of the UI to ask the user to confirm to sync SMS.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setSyncSmsTitle(@Nullable String syncSmsTitle) {
+            mBuilderParcel.syncSmsTitle = syncSmsTitle;
+            return this;
+        }
+
+        /**
+         * Set SyncSmsDescription.
+         *
+         * @param syncSmsDescription Description of the UI to ask the user to confirm to sync SMS.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setSyncSmsDescription(@Nullable String syncSmsDescription) {
+            mBuilderParcel.syncSmsDescription = syncSmsDescription;
+            return this;
+        }
+
+        /**
+         * Set AssistantSetupHalfSheet.
+         *
+         * @param assistantSetupHalfSheet Description in half sheet to ask user setup google
+         * assistant.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setAssistantSetupHalfSheet(@Nullable String assistantSetupHalfSheet) {
+            mBuilderParcel.assistantSetupHalfSheet = assistantSetupHalfSheet;
+            return this;
+        }
+
+        /**
+         * Set AssistantSetupNotification.
+         *
+         * @param assistantSetupNotification Description in notification to ask user setup google
+         *                                   assistant.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setAssistantSetupNotification(
+                @Nullable String assistantSetupNotification) {
+            mBuilderParcel.assistantSetupNotification = assistantSetupNotification;
+            return this;
+        }
+
+        /**
+         * Set FastPairTvConnectDeviceNoAccountDescription.
+         *
+         * @param fastPairTvConnectDeviceNoAccountDescription Description of the connect device
+         *                                                    action on TV, when user is not logged
+         *                                                    in.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setFastPairTvConnectDeviceNoAccountDescription(
+                @Nullable String fastPairTvConnectDeviceNoAccountDescription) {
+            mBuilderParcel.fastPairTvConnectDeviceNoAccountDescription =
+                    fastPairTvConnectDeviceNoAccountDescription;
+            return this;
+        }
+
+        /**
+         * Build {@link FastPairDeviceMetadata} with the currently set configuration.
+         *
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public FastPairDeviceMetadata build() {
+            return new FastPairDeviceMetadata(mBuilderParcel);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairDiscoveryItem.java b/nearby/framework/java/android/nearby/FastPairDiscoveryItem.java
new file mode 100644
index 0000000..bc6a6f8
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairDiscoveryItem.java
@@ -0,0 +1,825 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.nearby.aidl.FastPairDiscoveryItemParcel;
+
+/**
+ * Class for FastPairDiscoveryItem and its builder.
+ *
+ * @hide
+ */
+@SystemApi
+public class FastPairDiscoveryItem {
+
+    FastPairDiscoveryItemParcel mMetadataParcel;
+
+    FastPairDiscoveryItem(
+            FastPairDiscoveryItemParcel metadataParcel) {
+        this.mMetadataParcel = metadataParcel;
+    }
+
+    /**
+     * Get Id.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getId() {
+        return mMetadataParcel.id;
+    }
+
+    /**
+     * Get Type.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getType() {
+        return mMetadataParcel.type;
+    }
+
+    /**
+     * Get MacAddress.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getMacAddress() {
+        return mMetadataParcel.macAddress;
+    }
+
+    /**
+     * Get ActionUrl.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getActionUrl() {
+        return mMetadataParcel.actionUrl;
+    }
+
+    /**
+     * Get DeviceName.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getDeviceName() {
+        return mMetadataParcel.deviceName;
+    }
+
+    /**
+     * Get Title.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getTitle() {
+        return mMetadataParcel.title;
+    }
+
+    /**
+     * Get Description.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getDescription() {
+        return mMetadataParcel.description;
+    }
+
+    /**
+     * Get DisplayUrl.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getDisplayUrl() {
+        return mMetadataParcel.displayUrl;
+    }
+
+    /**
+     * Get LastObservationTimestampMillis.
+     *
+     * @hide
+     */
+    @SystemApi
+    public long getLastObservationTimestampMillis() {
+        return mMetadataParcel.lastObservationTimestampMillis;
+    }
+
+    /**
+     * Get FirstObservationTimestampMillis.
+     *
+     * @hide
+     */
+    @SystemApi
+    public long getFirstObservationTimestampMillis() {
+        return mMetadataParcel.firstObservationTimestampMillis;
+    }
+
+    /**
+     * Get State.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getState() {
+        return mMetadataParcel.state;
+    }
+
+    /**
+     * Get ActionUrlType.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getActionUrlType() {
+        return mMetadataParcel.actionUrlType;
+    }
+
+    /**
+     * Get Rssi.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getRssi() {
+        return mMetadataParcel.rssi;
+    }
+
+    /**
+     * Get PendingAppInstallTimestampMillis.
+     *
+     * @hide
+     */
+    @SystemApi
+    public long getPendingAppInstallTimestampMillis() {
+        return mMetadataParcel.pendingAppInstallTimestampMillis;
+    }
+
+    /**
+     * Get TxPower.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getTxPower() {
+        return mMetadataParcel.txPower;
+    }
+
+    /**
+     * Get AppName.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getAppName() {
+        return mMetadataParcel.appName;
+    }
+
+    /**
+     * Get GroupId.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getGroupId() {
+        return mMetadataParcel.groupId;
+    }
+
+    /**
+     * Get AttachmentType.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getAttachmentType() {
+        return mMetadataParcel.attachmentType;
+    }
+
+    /**
+     * Get PackageName.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getPackageName() {
+        return mMetadataParcel.packageName;
+    }
+
+    /**
+     * Get FeatureGraphicUrl.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getFeatureGraphicUrl() {
+        return mMetadataParcel.featureGraphicUrl;
+    }
+
+    /**
+     * Get TriggerId.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getTriggerId() {
+        return mMetadataParcel.triggerId;
+    }
+
+    /**
+     * Get IconPng, which is submitted at device registration time to display on notification. It is
+     * a 32-bit PNG with dimensions of 512px by 512px.
+     *
+     * @return IconPng in 32-bit PNG with dimensions of 512px by 512px.
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public byte[] getIconPng() {
+        return mMetadataParcel.iconPng;
+    }
+
+    /**
+     * Get IconFifeUrl.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getIconFfeUrl() {
+        return mMetadataParcel.iconFifeUrl;
+    }
+
+    /**
+     * Get DebugMessage.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getDebugMessage() {
+        return mMetadataParcel.debugMessage;
+    }
+
+    /**
+     * Get DebugCategory.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getDebugCategory() {
+        return mMetadataParcel.debugCategory;
+    }
+
+    /**
+     * Get LostMillis.
+     */
+    public long getLostMillis() {
+        return mMetadataParcel.lostMillis;
+    }
+
+    /**
+     * Get LastUserExperience.
+     *
+     * @hide
+     */
+    @SystemApi
+    public int getLastUserExperience() {
+        return mMetadataParcel.lastUserExperience;
+    }
+
+    /**
+     * Get BleRecordBytes. Raw bytes of {@link android.bluetooth.le.ScanRecord}.
+     * It is the most recent BLE advertisement related to this item.
+     *
+     * @return the most recent BLE advertisement in raw bytes of
+     *         {@link android.bluetooth.le.ScanRecord}.
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public byte[] getBleRecordBytes() {
+        return mMetadataParcel.bleRecordBytes;
+    }
+
+    /**
+     * Get EntityId.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public String getEntityId() {
+        return mMetadataParcel.entityId;
+    }
+
+    /**
+     * Get authenticationPublicKeySecp256r1, which is same as AntiSpoof public key, see
+     * <a href="https://developers.google.com/nearby/fast-pair/spec#data_format">Data Format</a>.
+     *
+     * @return 64-byte authenticationPublicKeySecp256r1.
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public byte[] getAuthenticationPublicKeySecp256r1() {
+        return mMetadataParcel.authenticationPublicKeySecp256r1;
+    }
+
+    /**
+     * Builder used to create FastPairDiscoveryItem.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final class Builder {
+
+        private final FastPairDiscoveryItemParcel mBuilderParcel;
+
+        /**
+         * Default constructor of Builder.
+         *
+         * @hide
+         */
+        @SystemApi
+        public Builder() {
+            mBuilderParcel = new FastPairDiscoveryItemParcel();
+        }
+
+        /**
+         * Set Id.
+         *
+         * @param id Unique id.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         *
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setId(@Nullable String id) {
+            mBuilderParcel.id = id;
+            return this;
+        }
+
+        /**
+         * Set Nearby Type.
+         *
+         * @param type Nearby type.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setType(int type) {
+            mBuilderParcel.type = type;
+            return this;
+        }
+
+        /**
+         * Set MacAddress.
+         *
+         * @param macAddress Fast Pair device rotating mac address.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setMacAddress(@Nullable String macAddress) {
+            mBuilderParcel.macAddress = macAddress;
+            return this;
+        }
+
+        /**
+         * Set ActionUrl.
+         *
+         * @param actionUrl Action Url of Fast Pair device.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setActionUrl(@Nullable String actionUrl) {
+            mBuilderParcel.actionUrl = actionUrl;
+            return this;
+        }
+
+        /**
+         * Set DeviceName.
+         * @param deviceName Fast Pair device name.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDeviceName(@Nullable String deviceName) {
+            mBuilderParcel.deviceName = deviceName;
+            return this;
+        }
+
+        /**
+         * Set Title.
+         *
+         * @param title Title of Fast Pair device.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setTitle(@Nullable String title) {
+            mBuilderParcel.title = title;
+            return this;
+        }
+
+        /**
+         * Set Description.
+         *
+         * @param description Description of Fast Pair device.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDescription(@Nullable String description) {
+            mBuilderParcel.description = description;
+            return this;
+        }
+
+        /**
+         * Set DisplayUrl.
+         *
+         * @param displayUrl Display Url of Fast Pair device.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDisplayUrl(@Nullable String displayUrl) {
+            mBuilderParcel.displayUrl = displayUrl;
+            return this;
+        }
+
+        /**
+         * Set LastObservationTimestampMillis.
+         *
+         * @param lastObservationTimestampMillis Last observed timestamp of Fast Pair device, keyed
+         *                                       by a rotating id.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setLastObservationTimestampMillis(
+                long lastObservationTimestampMillis) {
+            mBuilderParcel.lastObservationTimestampMillis = lastObservationTimestampMillis;
+            return this;
+        }
+
+        /**
+         * Set FirstObservationTimestampMillis.
+         *
+         * @param firstObservationTimestampMillis First observed timestamp of Fast Pair device,
+         *                                        keyed by a rotating id.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setFirstObservationTimestampMillis(
+                long firstObservationTimestampMillis) {
+            mBuilderParcel.firstObservationTimestampMillis = firstObservationTimestampMillis;
+            return this;
+        }
+
+        /**
+         * Set State.
+         *
+         * @param state Item's current state. e.g. if the item is blocked.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setState(int state) {
+            mBuilderParcel.state = state;
+            return this;
+        }
+
+        /**
+         * Set ActionUrlType.
+         *
+         * @param actionUrlType The resolved url type for the action_url.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setActionUrlType(int actionUrlType) {
+            mBuilderParcel.actionUrlType = actionUrlType;
+            return this;
+        }
+
+        /**
+         * Set Rssi.
+         *
+         * @param rssi Beacon's RSSI value.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setRssi(int rssi) {
+            mBuilderParcel.rssi = rssi;
+            return this;
+        }
+
+        /**
+         * Set PendingAppInstallTimestampMillis.
+         *
+         * @param pendingAppInstallTimestampMillis The timestamp when the user is redirected to App
+         *                                         Store after clicking on the item.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setPendingAppInstallTimestampMillis(long pendingAppInstallTimestampMillis) {
+            mBuilderParcel.pendingAppInstallTimestampMillis = pendingAppInstallTimestampMillis;
+            return this;
+        }
+
+        /**
+         * Set TxPower.
+         *
+         * @param txPower Beacon's tx power.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setTxPower(int txPower) {
+            mBuilderParcel.txPower = txPower;
+            return this;
+        }
+
+        /**
+         * Set AppName.
+         *
+         * @param appName Human readable name of the app designated to open the uri.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setAppName(@Nullable String appName) {
+            mBuilderParcel.appName = appName;
+            return this;
+        }
+
+        /**
+         * Set GroupId.
+         *
+         * @param groupId ID used for associating several DiscoveryItems. These items may be
+         *                visually displayed together.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setGroupId(@Nullable String groupId) {
+            mBuilderParcel.groupId = groupId;
+            return this;
+        }
+
+        /**
+         * Set AttachmentType.
+         *
+         * @param attachmentType Whether the attachment is created in debug namespace.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setAttachmentType(int attachmentType) {
+            mBuilderParcel.attachmentType = attachmentType;
+            return this;
+        }
+
+        /**
+         * Set PackageName.
+         *
+         * @param packageName Package name of the App that owns this item.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setPackageName(@Nullable String packageName) {
+            mBuilderParcel.packageName = packageName;
+            return this;
+        }
+
+        /**
+         * Set FeatureGraphicUrl.
+         *
+         * @param featureGraphicUrl The "feature" graphic image url used for large sized list view
+         *                          entries.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setFeatureGraphicUrl(@Nullable String featureGraphicUrl) {
+            mBuilderParcel.featureGraphicUrl = featureGraphicUrl;
+            return this;
+        }
+
+        /**
+         * Set TriggerId.
+         *
+         * @param triggerId TriggerId identifies the trigger/beacon that is attached with a message.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setTriggerId(@Nullable String triggerId) {
+            mBuilderParcel.triggerId = triggerId;
+            return this;
+        }
+
+        /**
+         * Set IconPng.
+         *
+         * @param iconPng Bytes of item icon in PNG format displayed in Discovery item list.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setIconPng(@Nullable byte[] iconPng) {
+            mBuilderParcel.iconPng = iconPng;
+            return this;
+        }
+
+        /**
+         * Set IconFifeUrl.
+         *
+         * @param iconFifeUrl A FIFE URL of the item icon displayed in Discovery item list.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setIconFfeUrl(@Nullable String iconFifeUrl) {
+            mBuilderParcel.iconFifeUrl = iconFifeUrl;
+            return this;
+        }
+
+        /**
+         * Set DebugMessage.
+         *
+         * @param debugMessage Message written to bugreport for 3P developers.(No sensitive info)
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDebugMessage(@Nullable String debugMessage) {
+            mBuilderParcel.debugMessage = debugMessage;
+            return this;
+        }
+
+        /**
+         * Set DebugCategory.
+         *
+         * @param debugCategory Weather the item is filtered out on server.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setDebugCategory(int debugCategory) {
+            mBuilderParcel.debugCategory = debugCategory;
+            return this;
+        }
+
+        /**
+         * Set LostMillis.
+         *
+         * @param lostMillis Client timestamp when the trigger (e.g. beacon) was last lost
+         *                   (e.g. when Messages told us the beacon's no longer nearby).
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setLostMillis(long lostMillis) {
+            mBuilderParcel.lostMillis = lostMillis;
+            return this;
+        }
+
+        /**
+         * Set LastUserExperience.
+         *
+         * @param lastUserExperience The kind of experience the user last had with this (e.g. if
+         *                           they dismissed the notification, that's bad; but if they tapped
+         *                           it, that's good).
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setLastUserExperience(int lastUserExperience) {
+            mBuilderParcel.lastUserExperience = lastUserExperience;
+            return this;
+        }
+
+        /**
+         * Set BleRecordBytes.
+         *
+         * @param bleRecordBytes The most recent BLE advertisement related to this item. Raw bytes
+         *                       of {@link android.bluetooth.le.ScanRecord}.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setBleRecordBytes(@Nullable byte[] bleRecordBytes) {
+            mBuilderParcel.bleRecordBytes = bleRecordBytes;
+            return this;
+        }
+
+        /**
+         * Set EntityId.
+         *
+         * @param entityId An ID generated on the server to uniquely identify content.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setEntityId(@Nullable String entityId) {
+            mBuilderParcel.entityId = entityId;
+            return this;
+        }
+
+        /**
+         * Set authenticationPublicKeySecp256r1, which is same as AntiSpoof public key, see
+         * <a href="https://developers.google.com/nearby/fast-pair/spec#data_format">Data Format</a>
+         *
+         * @param authenticationPublicKeySecp256r1 64-byte Fast Pair device public key.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setAuthenticationPublicKeySecp256r1(
+                @Nullable byte[] authenticationPublicKeySecp256r1) {
+            mBuilderParcel.authenticationPublicKeySecp256r1 = authenticationPublicKeySecp256r1;
+            return this;
+        }
+
+        /**
+         * Build {@link FastPairDiscoveryItem} with the currently set configuration.
+         *
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public FastPairDiscoveryItem build() {
+            return new FastPairDiscoveryItem(mBuilderParcel);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairEligibleAccount.java b/nearby/framework/java/android/nearby/FastPairEligibleAccount.java
new file mode 100644
index 0000000..e6c3047
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairEligibleAccount.java
@@ -0,0 +1,121 @@
+/*
+ * 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 android.nearby;
+
+import android.accounts.Account;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.nearby.aidl.FastPairEligibleAccountParcel;
+
+/**
+ * Class for FastPairEligibleAccount and its builder.
+ *
+ * @hide
+ */
+@SystemApi
+public class FastPairEligibleAccount {
+
+    FastPairEligibleAccountParcel mAccountParcel;
+
+    FastPairEligibleAccount(FastPairEligibleAccountParcel accountParcel) {
+        this.mAccountParcel = accountParcel;
+    }
+
+    /**
+     * Get Account.
+     *
+     * @hide
+     */
+    @SystemApi
+    @Nullable
+    public Account getAccount() {
+        return this.mAccountParcel.account;
+    }
+
+    /**
+     * Get OptIn Status.
+     *
+     * @hide
+     */
+    @SystemApi
+    public boolean isOptIn() {
+        return this.mAccountParcel.optIn;
+    }
+
+    /**
+     * Builder used to create FastPairEligibleAccount.
+     *
+     * @hide
+     */
+    @SystemApi
+    public static final class Builder {
+
+        private final FastPairEligibleAccountParcel mBuilderParcel;
+
+        /**
+         * Default constructor of Builder.
+         *
+         * @hide
+         */
+        @SystemApi
+        public Builder() {
+            mBuilderParcel = new FastPairEligibleAccountParcel();
+            mBuilderParcel.account = null;
+            mBuilderParcel.optIn = false;
+        }
+
+        /**
+         * Set Account.
+         *
+         * @param account Fast Pair eligible account.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setAccount(@Nullable Account account) {
+            mBuilderParcel.account = account;
+            return this;
+        }
+
+        /**
+         * Set whether the account is opt into Fast Pair.
+         *
+         * @param optIn Whether the Fast Pair eligible account opts into Fast Pair.
+         * @return The builder, to facilitate chaining {@code builder.setXXX(..).setXXX(..)}.
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setOptIn(boolean optIn) {
+            mBuilderParcel.optIn = optIn;
+            return this;
+        }
+
+        /**
+         * Build {@link FastPairEligibleAccount} with the currently set configuration.
+         *
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public FastPairEligibleAccount build() {
+            return new FastPairEligibleAccount(mBuilderParcel);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/FastPairStatusCallback.java b/nearby/framework/java/android/nearby/FastPairStatusCallback.java
new file mode 100644
index 0000000..1567828
--- /dev/null
+++ b/nearby/framework/java/android/nearby/FastPairStatusCallback.java
@@ -0,0 +1,30 @@
+/*
+ * 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 android.annotation.NonNull;
+
+/**
+ * Reports the pair status for an ongoing pair with a {@link FastPairDevice}.
+ * @hide
+ */
+public interface FastPairStatusCallback {
+
+    /** Reports a pair status related metadata associated with a {@link FastPairDevice} */
+    void onPairUpdate(@NonNull FastPairDevice fastPairDevice,
+            PairStatusMetadata pairStatusMetadata);
+}
diff --git a/nearby/framework/java/android/nearby/IBroadcastListener.aidl b/nearby/framework/java/android/nearby/IBroadcastListener.aidl
new file mode 100644
index 0000000..98c7e17
--- /dev/null
+++ b/nearby/framework/java/android/nearby/IBroadcastListener.aidl
@@ -0,0 +1,27 @@
+/*
+ * 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;
+
+/**
+ * Callback when brodacast status changes.
+ *
+ * {@hide}
+ */
+oneway interface IBroadcastListener {
+    /** Called when the broadcast status changes. */
+    void onStatusChanged(int status);
+}
diff --git a/nearby/framework/java/android/nearby/IFastPairHalfSheetCallback.aidl b/nearby/framework/java/android/nearby/IFastPairHalfSheetCallback.aidl
new file mode 100644
index 0000000..2e6fc87
--- /dev/null
+++ b/nearby/framework/java/android/nearby/IFastPairHalfSheetCallback.aidl
@@ -0,0 +1,25 @@
+// 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 android.nearby;
+
+import android.content.Intent;
+/**
+  * Provides callback interface for halfsheet to send FastPair call back.
+  *
+  * {@hide}
+  */
+interface IFastPairHalfSheetCallback {
+     void onHalfSheetConnectionConfirm(in Intent intent);
+ }
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/INearbyManager.aidl b/nearby/framework/java/android/nearby/INearbyManager.aidl
new file mode 100644
index 0000000..62e109e
--- /dev/null
+++ b/nearby/framework/java/android/nearby/INearbyManager.aidl
@@ -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 android.nearby;
+
+import android.nearby.IBroadcastListener;
+import android.nearby.IScanListener;
+import android.nearby.BroadcastRequestParcelable;
+import android.nearby.ScanRequest;
+
+/**
+ * Interface for communicating with the nearby services.
+ *
+ * @hide
+ */
+interface INearbyManager {
+
+    int registerScanListener(in ScanRequest scanRequest, in IScanListener listener);
+
+    void unregisterScanListener(in IScanListener listener);
+
+    void startBroadcast(in BroadcastRequestParcelable broadcastRequest,
+            in IBroadcastListener callback);
+
+    void stopBroadcast(in IBroadcastListener 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
new file mode 100644
index 0000000..54033aa
--- /dev/null
+++ b/nearby/framework/java/android/nearby/IScanListener.aidl
@@ -0,0 +1,35 @@
+/*
+ * 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 android.nearby.NearbyDeviceParcelable;
+
+/**
+ * Binder callback for ScanCallback.
+ *
+ * {@hide}
+ */
+oneway interface IScanListener {
+        /** Reports a {@link NearbyDevice} being discovered. */
+        void onDiscovered(in NearbyDeviceParcelable nearbyDeviceParcelable);
+
+        /** Reports a {@link NearbyDevice} information(distance, packet, and etc) changed. */
+        void onUpdated(in NearbyDeviceParcelable nearbyDeviceParcelable);
+
+        /** Reports a {@link NearbyDevice} is no longer within range. */
+        void onLost(in NearbyDeviceParcelable nearbyDeviceParcelable);
+}
diff --git a/nearby/framework/java/android/nearby/NearbyDevice.java b/nearby/framework/java/android/nearby/NearbyDevice.java
new file mode 100644
index 0000000..538940c
--- /dev/null
+++ b/nearby/framework/java/android/nearby/NearbyDevice.java
@@ -0,0 +1,151 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.IntDef;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+
+import com.android.internal.util.Preconditions;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A class represents a device that can be discovered by multiple mediums.
+ *
+ * @hide
+ */
+@SystemApi
+public abstract class NearbyDevice {
+
+    @Nullable
+    private final String mName;
+
+    @Medium
+    private final List<Integer> mMediums;
+
+    private final int mRssi;
+
+    /**
+     * Creates a new NearbyDevice.
+     *
+     * @param name Local device name. Can be {@code null} if there is no name.
+     * @param mediums The {@link Medium}s over which the device is discovered.
+     * @param rssi The received signal strength in dBm.
+     * @hide
+     */
+    public NearbyDevice(@Nullable String name, List<Integer> mediums, int rssi) {
+        for (int medium : mediums) {
+            Preconditions.checkState(isValidMedium(medium),
+                    "Not supported medium: " + medium
+                            + ", scan medium must be one of NearbyDevice#Medium.");
+        }
+        mName = name;
+        mMediums = mediums;
+        mRssi = rssi;
+    }
+
+    static String mediumToString(@Medium int medium) {
+        switch (medium) {
+            case Medium.BLE:
+                return "BLE";
+            case Medium.BLUETOOTH:
+                return "Bluetooth Classic";
+            default:
+                return "Unknown";
+        }
+    }
+
+    /**
+     * True if the medium is defined in {@link Medium}.
+     *
+     * @param medium Integer that may represent a medium type.
+     */
+    public static boolean isValidMedium(@Medium int medium) {
+        return medium == Medium.BLE
+                || medium == Medium.BLUETOOTH;
+    }
+
+    /**
+     * The name of the device, or null if not available.
+     */
+    @Nullable
+    public String getName() {
+        return mName;
+    }
+
+    /** The medium over which this device was discovered. */
+    @NonNull
+    @Medium public List<Integer> getMediums() {
+        return mMediums;
+    }
+
+    /**
+     * Returns the received signal strength in dBm.
+     */
+    @IntRange(from = -127, to = 126)
+    public int getRssi() {
+        return mRssi;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder stringBuilder = new StringBuilder();
+        stringBuilder.append("NearbyDevice [");
+        if (mName != null && !mName.isEmpty()) {
+            stringBuilder.append("name=").append(mName).append(", ");
+        }
+        stringBuilder.append("medium={");
+        for (int medium : mMediums) {
+            stringBuilder.append(mediumToString(medium));
+        }
+        stringBuilder.append("} rssi=").append(mRssi);
+        stringBuilder.append("]");
+        return stringBuilder.toString();
+    }
+
+    @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;
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mName, mMediums, mRssi);
+    }
+
+    /**
+     * The medium where a NearbyDevice was discovered on.
+     *
+     * @hide
+     */
+    @IntDef({Medium.BLE, Medium.BLUETOOTH})
+    public @interface Medium {
+        int BLE = 1;
+        int BLUETOOTH = 2;
+    }
+}
+
diff --git a/nearby/framework/java/android/nearby/NearbyDeviceParcelable.aidl b/nearby/framework/java/android/nearby/NearbyDeviceParcelable.aidl
new file mode 100644
index 0000000..1a88181
--- /dev/null
+++ b/nearby/framework/java/android/nearby/NearbyDeviceParcelable.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2012, 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;
+
+parcelable NearbyDeviceParcelable;
+
diff --git a/nearby/framework/java/android/nearby/NearbyDeviceParcelable.java b/nearby/framework/java/android/nearby/NearbyDeviceParcelable.java
new file mode 100644
index 0000000..1ad3571
--- /dev/null
+++ b/nearby/framework/java/android/nearby/NearbyDeviceParcelable.java
@@ -0,0 +1,437 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * A data class representing scan result from Nearby Service. Scan result can come from multiple
+ * mediums like BLE, Wi-Fi Aware, and etc. A scan result consists of An encapsulation of various
+ * parameters for requesting nearby scans.
+ *
+ * <p>All scan results generated through {@link NearbyManager} are guaranteed to have a valid
+ * medium, identifier, timestamp (both UTC time and elapsed real-time since boot), and accuracy. All
+ * other parameters are optional.
+ *
+ * @hide
+ */
+public final class NearbyDeviceParcelable implements Parcelable {
+
+    /** Used to read a NearbyDeviceParcelable from a Parcel. */
+    @NonNull
+    public static final Creator<NearbyDeviceParcelable> CREATOR =
+            new Creator<NearbyDeviceParcelable>() {
+                @Override
+                public NearbyDeviceParcelable createFromParcel(Parcel in) {
+                    Builder builder = new Builder();
+                    if (in.readInt() == 1) {
+                        builder.setName(in.readString());
+                    }
+                    builder.setMedium(in.readInt());
+                    builder.setTxPower(in.readInt());
+                    builder.setRssi(in.readInt());
+                    builder.setAction(in.readInt());
+                    builder.setPublicCredential(
+                            in.readParcelable(
+                                    PublicCredential.class.getClassLoader(),
+                                    PublicCredential.class));
+                    if (in.readInt() == 1) {
+                        builder.setFastPairModelId(in.readString());
+                    }
+                    if (in.readInt() == 1) {
+                        builder.setBluetoothAddress(in.readString());
+                    }
+                    if (in.readInt() == 1) {
+                        int dataLength = in.readInt();
+                        byte[] data = new byte[dataLength];
+                        in.readByteArray(data);
+                        builder.setData(data);
+                    }
+                    return builder.build();
+                }
+
+                @Override
+                public NearbyDeviceParcelable[] newArray(int size) {
+                    return new NearbyDeviceParcelable[size];
+                }
+            };
+
+    @ScanRequest.ScanType int mScanType;
+    @Nullable private final String mName;
+    @NearbyDevice.Medium private final int mMedium;
+    private final int mTxPower;
+    private final int mRssi;
+    private final int mAction;
+    private final PublicCredential mPublicCredential;
+    @Nullable private final String mBluetoothAddress;
+    @Nullable private final String mFastPairModelId;
+    @Nullable private final byte[] mData;
+
+    private NearbyDeviceParcelable(
+            @ScanRequest.ScanType int scanType,
+            @Nullable String name,
+            int medium,
+            int TxPower,
+            int rssi,
+            int action,
+            PublicCredential publicCredential,
+            @Nullable String fastPairModelId,
+            @Nullable String bluetoothAddress,
+            @Nullable byte[] data) {
+        mScanType = scanType;
+        mName = name;
+        mMedium = medium;
+        mTxPower = TxPower;
+        mRssi = rssi;
+        mAction = action;
+        mPublicCredential = publicCredential;
+        mFastPairModelId = fastPairModelId;
+        mBluetoothAddress = bluetoothAddress;
+        mData = data;
+    }
+
+    /** No special parcel contents. */
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    /**
+     * Flatten this NearbyDeviceParcelable in to a Parcel.
+     *
+     * @param dest The Parcel in which the object should be written.
+     * @param flags Additional flags about how the object should be written.
+     */
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mName == null ? 0 : 1);
+        if (mName != null) {
+            dest.writeString(mName);
+        }
+        dest.writeInt(mMedium);
+        dest.writeInt(mTxPower);
+        dest.writeInt(mRssi);
+        dest.writeInt(mAction);
+        dest.writeParcelable(mPublicCredential, flags);
+        dest.writeInt(mFastPairModelId == null ? 0 : 1);
+        if (mFastPairModelId != null) {
+            dest.writeString(mFastPairModelId);
+        }
+        dest.writeInt(mBluetoothAddress == null ? 0 : 1);
+        if (mBluetoothAddress != null) {
+            dest.writeString(mBluetoothAddress);
+        }
+        dest.writeInt(mData == null ? 0 : 1);
+        if (mData != null) {
+            dest.writeInt(mData.length);
+            dest.writeByteArray(mData);
+        }
+    }
+
+    /** Returns a string representation of this ScanRequest. */
+    @Override
+    public String toString() {
+        return "NearbyDeviceParcelable["
+                + "name="
+                + mName
+                + ", medium="
+                + NearbyDevice.mediumToString(mMedium)
+                + ", txPower="
+                + mTxPower
+                + ", rssi="
+                + mRssi
+                + ", action="
+                + mAction
+                + ", bluetoothAddress="
+                + mBluetoothAddress
+                + ", fastPairModelId="
+                + mFastPairModelId
+                + ", data="
+                + Arrays.toString(mData)
+                + "]";
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof NearbyDeviceParcelable) {
+            NearbyDeviceParcelable otherNearbyDeviceParcelable = (NearbyDeviceParcelable) other;
+            return Objects.equals(mName, otherNearbyDeviceParcelable.mName)
+                    && (mMedium == otherNearbyDeviceParcelable.mMedium)
+                    && (mTxPower == otherNearbyDeviceParcelable.mTxPower)
+                    && (mRssi == otherNearbyDeviceParcelable.mRssi)
+                    && (mAction == otherNearbyDeviceParcelable.mAction)
+                    && (Objects.equals(
+                            mPublicCredential, otherNearbyDeviceParcelable.mPublicCredential))
+                    && (Objects.equals(
+                            mBluetoothAddress, otherNearbyDeviceParcelable.mBluetoothAddress))
+                    && (Objects.equals(
+                            mFastPairModelId, otherNearbyDeviceParcelable.mFastPairModelId))
+                    && (Arrays.equals(mData, otherNearbyDeviceParcelable.mData));
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(
+                mName,
+                mMedium,
+                mRssi,
+                mAction,
+                mPublicCredential.hashCode(),
+                mBluetoothAddress,
+                mFastPairModelId,
+                Arrays.hashCode(mData));
+    }
+
+    /**
+     * Returns the type of the scan.
+     *
+     * @hide
+     */
+    @ScanRequest.ScanType
+    public int getScanType() {
+        return mScanType;
+    }
+
+    /** Gets the name of the NearbyDeviceParcelable. Returns {@code null} If there is no name. */
+    @Nullable
+    public String getName() {
+        return mName;
+    }
+
+    /**
+     * Gets the {@link android.nearby.NearbyDevice.Medium} of the NearbyDeviceParcelable over which
+     * it is discovered.
+     */
+    @NearbyDevice.Medium
+    public int getMedium() {
+        return mMedium;
+    }
+
+    /**
+     * Gets the transmission power in dBm.
+     *
+     * @hide
+     */
+    @IntRange(from = -127, to = 126)
+    public int getTxPower() {
+        return mTxPower;
+    }
+
+    /** Gets the received signal strength in dBm. */
+    @IntRange(from = -127, to = 126)
+    public int getRssi() {
+        return mRssi;
+    }
+
+    /**
+     * Gets the Action.
+     *
+     * @hide
+     */
+    @IntRange(from = -127, to = 126)
+    public int getAction() {
+        return mAction;
+    }
+
+    /**
+     * Gets the public credential.
+     *
+     * @hide
+     */
+    @NonNull
+    public PublicCredential getPublicCredential() {
+        return mPublicCredential;
+    }
+
+    /**
+     * Gets the Fast Pair identifier. Returns {@code null} if there is no Model ID or this is not a
+     * Fast Pair device.
+     */
+    @Nullable
+    public String getFastPairModelId() {
+        return mFastPairModelId;
+    }
+
+    /**
+     * Gets the Bluetooth device hardware address. Returns {@code null} if the device is not
+     * discovered by Bluetooth.
+     */
+    @Nullable
+    public String getBluetoothAddress() {
+        return mBluetoothAddress;
+    }
+
+    /** Gets the raw data from the scanning. Returns {@code null} if there is no extra data. */
+    @Nullable
+    public byte[] getData() {
+        return mData;
+    }
+
+    /** Builder class for {@link NearbyDeviceParcelable}. */
+    public static final class Builder {
+        @Nullable private String mName;
+        @NearbyDevice.Medium private int mMedium;
+        private int mTxPower;
+        private int mRssi;
+        private int mAction;
+        private PublicCredential mPublicCredential;
+        @ScanRequest.ScanType int mScanType;
+        @Nullable private String mFastPairModelId;
+        @Nullable private String mBluetoothAddress;
+        @Nullable private byte[] mData;
+
+        /**
+         * Sets the scan type of the NearbyDeviceParcelable.
+         *
+         * @hide
+         */
+        public Builder setScanType(@ScanRequest.ScanType int scanType) {
+            mScanType = scanType;
+            return this;
+        }
+
+        /**
+         * Sets the name of the scanned device.
+         *
+         * @param name The local name of the scanned device.
+         */
+        @NonNull
+        public Builder setName(@Nullable String name) {
+            mName = name;
+            return this;
+        }
+
+        /**
+         * Sets the medium over which the device is discovered.
+         *
+         * @param medium The {@link NearbyDevice.Medium} over which the device is discovered.
+         */
+        @NonNull
+        public Builder setMedium(@NearbyDevice.Medium int medium) {
+            mMedium = medium;
+            return this;
+        }
+
+        /**
+         * Sets the transmission power of the discovered device.
+         *
+         * @param txPower The transmission power in dBm.
+         * @hide
+         */
+        @NonNull
+        public Builder setTxPower(int txPower) {
+            mTxPower = txPower;
+            return this;
+        }
+
+        /**
+         * Sets the RSSI between scanned device and the discovered device.
+         *
+         * @param rssi The received signal strength in dBm.
+         */
+        @NonNull
+        public Builder setRssi(@IntRange(from = -127, to = 126) int rssi) {
+            mRssi = rssi;
+            return this;
+        }
+
+        /**
+         * Sets the action from the discovered device.
+         *
+         * @param action The action of the discovered device.
+         * @hide
+         */
+        @NonNull
+        public Builder setAction(int action) {
+            mAction = action;
+            return this;
+        }
+
+        /**
+         * Sets the public credential of the discovered device.
+         *
+         * @param publicCredential The public credential.
+         * @hide
+         */
+        @NonNull
+        public Builder setPublicCredential(@NonNull PublicCredential publicCredential) {
+            mPublicCredential = publicCredential;
+            return this;
+        }
+
+        /**
+         * Sets the Fast Pair model Id.
+         *
+         * @param fastPairModelId Fast Pair device identifier.
+         */
+        @NonNull
+        public Builder setFastPairModelId(@Nullable String fastPairModelId) {
+            mFastPairModelId = fastPairModelId;
+            return this;
+        }
+
+        /**
+         * Sets the bluetooth address.
+         *
+         * @param bluetoothAddress The hardware address of the bluetooth device.
+         */
+        @NonNull
+        public Builder setBluetoothAddress(@Nullable String bluetoothAddress) {
+            mBluetoothAddress = bluetoothAddress;
+            return this;
+        }
+
+        /**
+         * Sets the scanned raw data.
+         *
+         * @param data Data the scan. For example, {@link ScanRecord#getServiceData()} if scanned by
+         *     Bluetooth.
+         */
+        @NonNull
+        public Builder setData(@Nullable byte[] data) {
+            mData = data;
+            return this;
+        }
+
+        /** Builds a ScanResult. */
+        @NonNull
+        public NearbyDeviceParcelable build() {
+            return new NearbyDeviceParcelable(
+                    mScanType,
+                    mName,
+                    mMedium,
+                    mTxPower,
+                    mRssi,
+                    mAction,
+                    mPublicCredential,
+                    mFastPairModelId,
+                    mBluetoothAddress,
+                    mData);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/NearbyFrameworkInitializer.java b/nearby/framework/java/android/nearby/NearbyFrameworkInitializer.java
new file mode 100644
index 0000000..3780fbb
--- /dev/null
+++ b/nearby/framework/java/android/nearby/NearbyFrameworkInitializer.java
@@ -0,0 +1,50 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.SystemApi;
+import android.app.SystemServiceRegistry;
+import android.content.Context;
+
+/**
+ * Class for performing registration for all Nearby services.
+ *
+ * @hide
+ */
+@SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+public final class NearbyFrameworkInitializer {
+
+    private NearbyFrameworkInitializer() {}
+
+    /**
+     * Called by {@link SystemServiceRegistry}'s static initializer and registers all
+     * Nearby services to {@link Context}, so that {@link Context#getSystemService} can return them.
+     *
+     * @throws IllegalStateException if this is called from anywhere besides
+     * {@link SystemServiceRegistry}
+     */
+    public static void registerServiceWrappers() {
+        SystemServiceRegistry.registerContextAwareService(
+                Context.NEARBY_SERVICE,
+                NearbyManager.class,
+                (context, serviceBinder) -> {
+                    INearbyManager service = INearbyManager.Stub.asInterface(serviceBinder);
+                    return new NearbyManager(service);
+                }
+        );
+    }
+}
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
new file mode 100644
index 0000000..1c1d59f
--- /dev/null
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright 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 android.nearby;
+
+import android.Manifest;
+import android.annotation.CallbackExecutor;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SuppressLint;
+import android.annotation.SystemApi;
+import android.annotation.SystemService;
+import android.content.Context;
+import android.os.RemoteException;
+import android.provider.Settings;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.Preconditions;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+import java.util.WeakHashMap;
+import java.util.concurrent.Executor;
+
+/**
+ * This class provides a way to perform Nearby related operations such as scanning, broadcasting
+ * and connecting to nearby devices.
+ *
+ * <p> To get a {@link NearbyManager} instance, call the
+ * <code>Context.getSystemService(NearbyManager.class)</code>.
+ *
+ * @hide
+ */
+@SystemApi
+@SystemService(Context.NEARBY_SERVICE)
+public class NearbyManager {
+
+    /**
+     * Represents the scanning state.
+     *
+     * @hide
+     */
+    @IntDef({
+            ScanStatus.UNKNOWN,
+            ScanStatus.SUCCESS,
+            ScanStatus.ERROR,
+    })
+    public @interface ScanStatus {
+        // Default, invalid state.
+        int UNKNOWN = 0;
+        // The successful state.
+        int SUCCESS = 1;
+        // Failed state.
+        int ERROR = 2;
+    }
+
+    /**
+     * Whether allows Fast Pair to scan.
+     *
+     * (0 = disabled, 1 = enabled)
+     *
+     * @hide
+     */
+    public static final String FAST_PAIR_SCAN_ENABLED = "fast_pair_scan_enabled";
+
+    @GuardedBy("sScanListeners")
+    private static final WeakHashMap<ScanCallback, WeakReference<ScanListenerTransport>>
+            sScanListeners = new WeakHashMap<>();
+    @GuardedBy("sBroadcastListeners")
+    private static final WeakHashMap<BroadcastCallback, WeakReference<BroadcastListenerTransport>>
+            sBroadcastListeners = new WeakHashMap<>();
+
+    private final INearbyManager mService;
+
+    /**
+     * Creates a new NearbyManager.
+     *
+     * @param service the service object
+     */
+    NearbyManager(@NonNull INearbyManager service) {
+        mService = service;
+    }
+
+    private static NearbyDevice toClientNearbyDevice(
+            NearbyDeviceParcelable nearbyDeviceParcelable,
+            @ScanRequest.ScanType int scanType) {
+        if (scanType == ScanRequest.SCAN_TYPE_FAST_PAIR) {
+            return new FastPairDevice.Builder()
+                    .setName(nearbyDeviceParcelable.getName())
+                    .addMedium(nearbyDeviceParcelable.getMedium())
+                    .setRssi(nearbyDeviceParcelable.getRssi())
+                    .setTxPower(nearbyDeviceParcelable.getTxPower())
+                    .setModelId(nearbyDeviceParcelable.getFastPairModelId())
+                    .setBluetoothAddress(nearbyDeviceParcelable.getBluetoothAddress())
+                    .setData(nearbyDeviceParcelable.getData()).build();
+        }
+        return null;
+    }
+
+    /**
+     * Start scan for nearby devices with given parameters. Devices matching {@link ScanRequest}
+     * will be delivered through the given callback.
+     *
+     * @param scanRequest various parameters clients send when requesting scanning
+     * @param executor executor where the listener method is called
+     * @param scanCallback the callback to notify clients when there is a scan result
+     *
+     * @return whether scanning was successfully started
+     */
+    @RequiresPermission(allOf = {android.Manifest.permission.BLUETOOTH_SCAN,
+            android.Manifest.permission.BLUETOOTH_PRIVILEGED})
+    @ScanStatus
+    public int startScan(@NonNull ScanRequest scanRequest,
+            @CallbackExecutor @NonNull Executor executor,
+            @NonNull ScanCallback scanCallback) {
+        Objects.requireNonNull(scanRequest, "scanRequest must not be null");
+        Objects.requireNonNull(scanCallback, "scanCallback must not be null");
+        Objects.requireNonNull(executor, "executor must not be null");
+
+        try {
+            synchronized (sScanListeners) {
+                WeakReference<ScanListenerTransport> reference = sScanListeners.get(scanCallback);
+                ScanListenerTransport transport = reference != null ? reference.get() : null;
+                if (transport == null) {
+                    transport = new ScanListenerTransport(scanRequest.getScanType(), scanCallback,
+                            executor);
+                } else {
+                    Preconditions.checkState(transport.isRegistered());
+                    transport.setExecutor(executor);
+                }
+                @ScanStatus int status = mService.registerScanListener(scanRequest, transport);
+                if (status != ScanStatus.SUCCESS) {
+                    return status;
+                }
+                sScanListeners.put(scanCallback, new WeakReference<>(transport));
+                return ScanStatus.SUCCESS;
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Stops the nearby device scan for the specified callback. The given callback
+     * is guaranteed not to receive any invocations that happen after this method
+     * is invoked.
+     *
+     * Suppressed lint: Registration methods should have overload that accepts delivery Executor.
+     * Already have executor in startScan() method.
+     *
+     * @param scanCallback the callback that was used to start the scan
+     */
+    @SuppressLint("ExecutorRegistration")
+    @RequiresPermission(allOf = {android.Manifest.permission.BLUETOOTH_SCAN,
+            android.Manifest.permission.BLUETOOTH_PRIVILEGED})
+    public void stopScan(@NonNull ScanCallback scanCallback) {
+        Preconditions.checkArgument(scanCallback != null,
+                "invalid null scanCallback");
+        try {
+            synchronized (sScanListeners) {
+                WeakReference<ScanListenerTransport> reference = sScanListeners.remove(
+                        scanCallback);
+                ScanListenerTransport transport = reference != null ? reference.get() : null;
+                if (transport != null) {
+                    transport.unregister();
+                    mService.unregisterScanListener(transport);
+                }
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Start broadcasting the request using nearby specification.
+     *
+     * @param broadcastRequest request for the nearby broadcast
+     * @param executor executor for running the callback
+     * @param callback callback for notifying the client
+     */
+    @RequiresPermission(allOf = {Manifest.permission.BLUETOOTH_ADVERTISE,
+            android.Manifest.permission.BLUETOOTH_PRIVILEGED})
+    public void startBroadcast(@NonNull BroadcastRequest broadcastRequest,
+            @CallbackExecutor @NonNull Executor executor, @NonNull BroadcastCallback callback) {
+        try {
+            synchronized (sBroadcastListeners) {
+                WeakReference<BroadcastListenerTransport> reference = sBroadcastListeners.get(
+                        callback);
+                BroadcastListenerTransport transport = reference != null ? reference.get() : null;
+                if (transport == null) {
+                    transport = new BroadcastListenerTransport(callback, executor);
+                } else {
+                    Preconditions.checkState(transport.isRegistered());
+                    transport.setExecutor(executor);
+                }
+                mService.startBroadcast(new BroadcastRequestParcelable(broadcastRequest),
+                        transport);
+                sBroadcastListeners.put(callback, new WeakReference<>(transport));
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Stop the broadcast associated with the given callback.
+     *
+     * @param callback the callback that was used for starting the broadcast
+     */
+    @SuppressLint("ExecutorRegistration")
+    @RequiresPermission(allOf = {Manifest.permission.BLUETOOTH_ADVERTISE,
+            android.Manifest.permission.BLUETOOTH_PRIVILEGED})
+    public void stopBroadcast(@NonNull BroadcastCallback callback) {
+        try {
+            synchronized (sBroadcastListeners) {
+                WeakReference<BroadcastListenerTransport> reference = sBroadcastListeners.remove(
+                        callback);
+                BroadcastListenerTransport transport = reference != null ? reference.get() : null;
+                if (transport != null) {
+                    transport.unregister();
+                    mService.stopBroadcast(transport);
+                }
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * 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
+     */
+    public static boolean getFastPairScanEnabled(@NonNull Context context) {
+        final int enabled = Settings.Secure.getInt(
+                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, 0);
+        return enabled != 0;
+    }
+
+    /**
+     * 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
+     */
+    public static void setFastPairScanEnabled(@NonNull Context context, boolean enable) {
+        Settings.Secure.putInt(
+                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, enable ? 1 : 0);
+    }
+
+    private static class ScanListenerTransport extends IScanListener.Stub {
+
+        private @ScanRequest.ScanType int mScanType;
+        private volatile @Nullable ScanCallback mScanCallback;
+        private Executor mExecutor;
+
+        ScanListenerTransport(@ScanRequest.ScanType int scanType, ScanCallback scanCallback,
+                @CallbackExecutor Executor executor) {
+            Preconditions.checkArgument(scanCallback != null,
+                    "invalid null callback");
+            Preconditions.checkState(ScanRequest.isValidScanType(scanType),
+                    "invalid scan type : " + scanType
+                            + ", scan type must be one of ScanRequest#SCAN_TYPE_");
+            mScanType = scanType;
+            mScanCallback = scanCallback;
+            mExecutor = executor;
+        }
+
+        void setExecutor(Executor executor) {
+            Preconditions.checkArgument(
+                    executor != null, "invalid null executor");
+            mExecutor = executor;
+        }
+
+        boolean isRegistered() {
+            return mScanCallback != null;
+        }
+
+        void unregister() {
+            mScanCallback = null;
+        }
+
+        @Override
+        public void onDiscovered(NearbyDeviceParcelable nearbyDeviceParcelable)
+                throws RemoteException {
+            mExecutor.execute(() -> mScanCallback.onDiscovered(
+                    toClientNearbyDevice(nearbyDeviceParcelable, mScanType)));
+        }
+
+        @Override
+        public void onUpdated(NearbyDeviceParcelable nearbyDeviceParcelable)
+                throws RemoteException {
+            mExecutor.execute(
+                    () -> mScanCallback.onUpdated(
+                            toClientNearbyDevice(nearbyDeviceParcelable, mScanType)));
+        }
+
+        @Override
+        public void onLost(NearbyDeviceParcelable nearbyDeviceParcelable) throws RemoteException {
+            mExecutor.execute(
+                    () -> mScanCallback.onLost(
+                            toClientNearbyDevice(nearbyDeviceParcelable, mScanType)));
+        }
+    }
+
+    private static class BroadcastListenerTransport extends IBroadcastListener.Stub {
+        private volatile @Nullable BroadcastCallback mBroadcastCallback;
+        private Executor mExecutor;
+
+        BroadcastListenerTransport(BroadcastCallback broadcastCallback,
+                @CallbackExecutor Executor executor) {
+            mBroadcastCallback = broadcastCallback;
+            mExecutor = executor;
+        }
+
+        void setExecutor(Executor executor) {
+            Preconditions.checkArgument(
+                    executor != null, "invalid null executor");
+            mExecutor = executor;
+        }
+
+        boolean isRegistered() {
+            return mBroadcastCallback != null;
+        }
+
+        void unregister() {
+            mBroadcastCallback = null;
+        }
+
+        @Override
+        public void onStatusChanged(int status) {
+            mExecutor.execute(() -> {
+                if (mBroadcastCallback != null) {
+                    mBroadcastCallback.onStatusChanged(status);
+                }
+            });
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PairStatusMetadata.aidl b/nearby/framework/java/android/nearby/PairStatusMetadata.aidl
new file mode 100644
index 0000000..911a300
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PairStatusMetadata.aidl
@@ -0,0 +1,24 @@
+/*
+ * 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;
+
+/**
+ * Metadata about an ongoing paring. Wraps transient data like status and progress.
+ *
+ * @hide
+ */
+parcelable PairStatusMetadata;
diff --git a/nearby/framework/java/android/nearby/PairStatusMetadata.java b/nearby/framework/java/android/nearby/PairStatusMetadata.java
new file mode 100644
index 0000000..438cd6b
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PairStatusMetadata.java
@@ -0,0 +1,117 @@
+/*
+ * 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 android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.Objects;
+
+/**
+ * Metadata about an ongoing paring. Wraps transient data like status and progress.
+ *
+ * @hide
+ */
+public final class PairStatusMetadata implements Parcelable {
+
+    @Status
+    private final int mStatus;
+
+    /** The status of the pairing. */
+    @IntDef({
+            Status.UNKNOWN,
+            Status.SUCCESS,
+            Status.FAIL,
+            Status.DISMISS
+    })
+    public @interface Status {
+        int UNKNOWN = 1000;
+        int SUCCESS = 1001;
+        int FAIL = 1002;
+        int DISMISS = 1003;
+    }
+
+    /** Converts the status to readable string. */
+    public static String statusToString(@Status int status) {
+        switch (status) {
+            case Status.SUCCESS:
+                return "SUCCESS";
+            case Status.FAIL:
+                return "FAIL";
+            case Status.DISMISS:
+                return "DISMISS";
+            case Status.UNKNOWN:
+            default:
+                return "UNKNOWN";
+        }
+    }
+
+    public int getStatus() {
+        return mStatus;
+    }
+
+    @Override
+    public String toString() {
+        return "PairStatusMetadata[ status=" + statusToString(mStatus) + "]";
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof PairStatusMetadata) {
+            return mStatus == ((PairStatusMetadata) other).mStatus;
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mStatus);
+    }
+
+    public PairStatusMetadata(@Status int status) {
+        mStatus = status;
+    }
+
+    public static final Creator<PairStatusMetadata> CREATOR = new Creator<PairStatusMetadata>() {
+        @Override
+        public PairStatusMetadata createFromParcel(Parcel in) {
+            return new PairStatusMetadata(in.readInt());
+        }
+
+        @Override
+        public PairStatusMetadata[] newArray(int size) {
+            return new PairStatusMetadata[size];
+        }
+    };
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public int getStability() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mStatus);
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PresenceBroadcastRequest.java b/nearby/framework/java/android/nearby/PresenceBroadcastRequest.java
new file mode 100644
index 0000000..d01be06
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PresenceBroadcastRequest.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 android.nearby;
+
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.internal.util.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Request for Nearby Presence Broadcast.
+ *
+ * @hide
+ */
+@SystemApi
+public final class PresenceBroadcastRequest extends BroadcastRequest implements Parcelable {
+    private final byte[] mSalt;
+    private final List<Integer> mActions;
+    private final PrivateCredential mCredential;
+    private final List<DataElement> mExtendedProperties;
+
+    private PresenceBroadcastRequest(@BroadcastVersion int version, int txPower,
+            List<Integer> mediums, byte[] salt, List<Integer> actions,
+            PrivateCredential credential, List<DataElement> extendedProperties) {
+        super(BROADCAST_TYPE_NEARBY_PRESENCE, version, txPower, mediums);
+        mSalt = salt;
+        mActions = actions;
+        mCredential = credential;
+        mExtendedProperties = extendedProperties;
+    }
+
+    private PresenceBroadcastRequest(Parcel in) {
+        super(BROADCAST_TYPE_NEARBY_PRESENCE, in);
+        mSalt = new byte[in.readInt()];
+        in.readByteArray(mSalt);
+
+        mActions = new ArrayList<>();
+        in.readList(mActions, Integer.class.getClassLoader(), Integer.class);
+        mCredential = in.readParcelable(PrivateCredential.class.getClassLoader(),
+                PrivateCredential.class);
+        mExtendedProperties = new ArrayList<>();
+        in.readList(mExtendedProperties, DataElement.class.getClassLoader(), DataElement.class);
+    }
+
+    @NonNull
+    public static final Creator<PresenceBroadcastRequest> CREATOR =
+            new Creator<PresenceBroadcastRequest>() {
+                @Override
+                public PresenceBroadcastRequest createFromParcel(Parcel in) {
+                    // Skip Broadcast request type - it's used by parent class.
+                    in.readInt();
+                    return createFromParcelBody(in);
+                }
+
+                @Override
+                public PresenceBroadcastRequest[] newArray(int size) {
+                    return new PresenceBroadcastRequest[size];
+                }
+            };
+
+    static PresenceBroadcastRequest createFromParcelBody(Parcel in) {
+        return new PresenceBroadcastRequest(in);
+    }
+
+    /**
+     * Returns the salt associated with this broadcast request.
+     */
+    @NonNull
+    public byte[] getSalt() {
+        return mSalt;
+    }
+
+    /**
+     * Returns actions associated with this broadcast request.
+     */
+    @NonNull
+    public List<Integer> getActions() {
+        return mActions;
+    }
+
+    /**
+     * Returns the private credential associated with this broadcast request.
+     */
+    @NonNull
+    public PrivateCredential getCredential() {
+        return mCredential;
+    }
+
+    /**
+     * Returns extended property information associated with this broadcast request.
+     */
+    @NonNull
+    public List<DataElement> getExtendedProperties() {
+        return mExtendedProperties;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        super.writeToParcel(dest, flags);
+        dest.writeInt(mSalt.length);
+        dest.writeByteArray(mSalt);
+        dest.writeList(mActions);
+        dest.writeParcelable(mCredential, /** parcelableFlags= */0);
+        dest.writeList(mExtendedProperties);
+    }
+
+    /**
+     * Builder for {@link PresenceBroadcastRequest}.
+     */
+    public static final class Builder {
+        private final List<Integer> mMediums;
+        private final List<Integer> mActions;
+        private final List<DataElement> mExtendedProperties;
+        private final byte[] mSalt;
+        private final PrivateCredential mCredential;
+
+        private int mVersion;
+        private int mTxPower;
+
+        public Builder(@NonNull List<Integer> mediums, @NonNull byte[] salt,
+                @NonNull PrivateCredential credential) {
+            Preconditions.checkState(!mediums.isEmpty(), "mediums cannot be empty");
+            Preconditions.checkState(salt != null && salt.length > 0, "salt cannot be empty");
+
+            mVersion = PRESENCE_VERSION_V0;
+            mTxPower = UNKNOWN_TX_POWER;
+            mCredential = credential;
+            mActions = new ArrayList<>();
+            mExtendedProperties = new ArrayList<>();
+
+            mSalt = salt;
+            mMediums = mediums;
+        }
+
+        /**
+         * Sets the version for this request.
+         */
+        @NonNull
+        public Builder setVersion(@BroadcastVersion int version) {
+            mVersion = version;
+            return this;
+        }
+
+        /**
+         * Sets the calibrated tx power level in dBm for this request. The tx power level should
+         * be between -127 dBm and 126 dBm.
+         */
+        @NonNull
+        public Builder setTxPower(@IntRange(from = -127, to = 126) int txPower) {
+            mTxPower = txPower;
+            return this;
+        }
+
+        /**
+         * Adds an action for the presence broadcast request.
+         */
+        @NonNull
+        public Builder addAction(@IntRange(from = 1, to = 255) int action) {
+            mActions.add(action);
+            return this;
+        }
+
+        /**
+         * Adds an extended property for the presence broadcast request.
+         */
+        @NonNull
+        public Builder addExtendedProperty(@NonNull DataElement dataElement) {
+            Objects.requireNonNull(dataElement);
+            mExtendedProperties.add(dataElement);
+            return this;
+        }
+
+        /**
+         * Builds a {@link PresenceBroadcastRequest}.
+         */
+        @NonNull
+        public PresenceBroadcastRequest build() {
+            return new PresenceBroadcastRequest(mVersion, mTxPower, mMediums, mSalt, mActions,
+                    mCredential, mExtendedProperties);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PresenceCredential.java b/nearby/framework/java/android/nearby/PresenceCredential.java
new file mode 100644
index 0000000..1e851d6
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PresenceCredential.java
@@ -0,0 +1,174 @@
+/*
+ * 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 android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+
+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;
+
+/**
+ * Represents a credential for Nearby Presence.
+ *
+ * @hide
+ */
+@SystemApi
+public abstract class PresenceCredential {
+    /** Private credential type. */
+    public static final int CREDENTIAL_TYPE_PRIVATE = 0;
+
+    /** Public credential type. */
+    public static final int CREDENTIAL_TYPE_PUBLIC = 1;
+
+    /**
+     * @hide *
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({CREDENTIAL_TYPE_PUBLIC, CREDENTIAL_TYPE_PRIVATE})
+    public @interface CredentialType {}
+
+    /** Unknown identity type. */
+    public static final int IDENTITY_TYPE_UNKNOWN = 0;
+
+    /** Private identity type. */
+    public static final int IDENTITY_TYPE_PRIVATE = 1;
+    /** Provisioned identity type. */
+    public static final int IDENTITY_TYPE_PROVISIONED = 2;
+    /** Trusted identity type. */
+    public static final int IDENTITY_TYPE_TRUSTED = 3;
+    /** Public identity type. */
+    public static final int IDENTITY_TYPE_PUBLIC = 4;
+
+    /**
+     * @hide *
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({
+        IDENTITY_TYPE_UNKNOWN,
+        IDENTITY_TYPE_PRIVATE,
+        IDENTITY_TYPE_PROVISIONED,
+        IDENTITY_TYPE_TRUSTED,
+        IDENTITY_TYPE_PUBLIC
+    })
+    public @interface IdentityType {}
+
+    private final @CredentialType int mType;
+    private final @IdentityType int mIdentityType;
+    private final byte[] mSecretId;
+    private final byte[] mAuthenticityKey;
+    private final List<CredentialElement> mCredentialElements;
+
+    PresenceCredential(
+            @CredentialType int type,
+            @IdentityType int identityType,
+            byte[] secretId,
+            byte[] authenticityKey,
+            List<CredentialElement> credentialElements) {
+        mType = type;
+        mIdentityType = identityType;
+        mSecretId = secretId;
+        mAuthenticityKey = authenticityKey;
+        mCredentialElements = credentialElements;
+    }
+
+    PresenceCredential(@CredentialType int type, Parcel in) {
+        mType = type;
+        mIdentityType = in.readInt();
+        mSecretId = new byte[in.readInt()];
+        in.readByteArray(mSecretId);
+        mAuthenticityKey = new byte[in.readInt()];
+        in.readByteArray(mAuthenticityKey);
+        mCredentialElements = new ArrayList<>();
+        in.readList(
+                mCredentialElements,
+                CredentialElement.class.getClassLoader(),
+                CredentialElement.class);
+    }
+
+    /** Returns the type of the credential. */
+    public @CredentialType int getType() {
+        return mType;
+    }
+
+    /** Returns the identity type of the credential. */
+    public @IdentityType int getIdentityType() {
+        return mIdentityType;
+    }
+
+    /** Returns the secret id of the credential. */
+    @NonNull
+    public byte[] getSecretId() {
+        return mSecretId;
+    }
+
+    /** Returns the authenticity key of the credential. */
+    @NonNull
+    public byte[] getAuthenticityKey() {
+        return mAuthenticityKey;
+    }
+
+    /** Returns the elements of the credential. */
+    @NonNull
+    public List<CredentialElement> getCredentialElements() {
+        return mCredentialElements;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj instanceof PresenceCredential) {
+            PresenceCredential that = (PresenceCredential) obj;
+            return mType == that.mType
+                    && mIdentityType == that.mIdentityType
+                    && Arrays.equals(mSecretId, that.mSecretId)
+                    && Arrays.equals(mAuthenticityKey, that.mAuthenticityKey)
+                    && mCredentialElements.equals(that.mCredentialElements);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(
+                mType,
+                mIdentityType,
+                Arrays.hashCode(mSecretId),
+                Arrays.hashCode(mAuthenticityKey),
+                mCredentialElements.hashCode());
+    }
+
+    /**
+     * Writes the presence credential to the parcel.
+     *
+     * @hide
+     */
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mType);
+        dest.writeInt(mIdentityType);
+        dest.writeInt(mSecretId.length);
+        dest.writeByteArray(mSecretId);
+        dest.writeInt(mAuthenticityKey.length);
+        dest.writeByteArray(mAuthenticityKey);
+        dest.writeList(mCredentialElements);
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PresenceDevice.java b/nearby/framework/java/android/nearby/PresenceDevice.java
new file mode 100644
index 0000000..12fc2a3
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PresenceDevice.java
@@ -0,0 +1,373 @@
+/*
+ * 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 android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a Presence device from nearby scans.
+ *
+ * @hide
+ */
+@SystemApi
+public final class PresenceDevice extends NearbyDevice implements Parcelable {
+
+    /** The type of presence device. */
+    /** @hide **/
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({
+            DeviceType.UNKNOWN,
+            DeviceType.PHONE,
+            DeviceType.TABLET,
+            DeviceType.DISPLAY,
+            DeviceType.LAPTOP,
+            DeviceType.TV,
+            DeviceType.WATCH,
+    })
+    public @interface DeviceType {
+        /** The type of the device is unknown. */
+        int UNKNOWN = 0;
+        /** The device is a phone. */
+        int PHONE = 1;
+        /** The device is a tablet. */
+        int TABLET = 2;
+        /** The device is a display. */
+        int DISPLAY = 3;
+        /** The device is a laptop. */
+        int LAPTOP = 4;
+        /** The device is a TV. */
+        int TV = 5;
+        /** The device is a watch. */
+        int WATCH = 6;
+    }
+
+    private final String mDeviceId;
+    private final byte[] mSalt;
+    private final byte[] mSecretId;
+    private final byte[] mEncryptedIdentity;
+    private final int mDeviceType;
+    private final String mDeviceImageUrl;
+    private final long mDiscoveryTimestampMillis;
+    private final List<DataElement> mExtendedProperties;
+
+    /**
+     * The id of the device.
+     *
+     * <p>This id is not a hardware id. It may rotate based on the remote device's broadcasts.
+     */
+    @NonNull
+    public String getDeviceId() {
+        return mDeviceId;
+    }
+
+    /**
+     * Returns the salt used when presence device is discovered.
+     */
+    @NonNull
+    public byte[] getSalt() {
+        return mSalt;
+    }
+
+    /**
+     * Returns the secret used when presence device is discovered.
+     */
+    @NonNull
+    public byte[] getSecretId() {
+        return mSecretId;
+    }
+
+    /**
+     * Returns the encrypted identity used when presence device is discovered.
+     */
+    @NonNull
+    public byte[] getEncryptedIdentity() {
+        return mEncryptedIdentity;
+    }
+
+    /** The type of the device. */
+    @DeviceType
+    public int getDeviceType() {
+        return mDeviceType;
+    }
+
+    /** An image URL representing the device. */
+    @Nullable
+    public String getDeviceImageUrl() {
+        return mDeviceImageUrl;
+    }
+
+    /** The timestamp (since boot) when the device is discovered. */
+    public long getDiscoveryTimestampMillis() {
+        return mDiscoveryTimestampMillis;
+    }
+
+    /**
+     * The extended properties of the device.
+     */
+    @NonNull
+    public List<DataElement> getExtendedProperties() {
+        return mExtendedProperties;
+    }
+
+    private PresenceDevice(String deviceName, List<Integer> mMediums, int rssi, String deviceId,
+            byte[] salt, byte[] secretId, byte[] encryptedIdentity, int deviceType,
+            String deviceImageUrl, long discoveryTimestampMillis,
+            List<DataElement> extendedProperties) {
+        super(deviceName, mMediums, rssi);
+        mDeviceId = deviceId;
+        mSalt = salt;
+        mSecretId = secretId;
+        mEncryptedIdentity = encryptedIdentity;
+        mDeviceType = deviceType;
+        mDeviceImageUrl = deviceImageUrl;
+        mDiscoveryTimestampMillis = discoveryTimestampMillis;
+        mExtendedProperties = extendedProperties;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        String name = getName();
+        dest.writeInt(name == null ? 0 : 1);
+        if (name != null) {
+            dest.writeString(name);
+        }
+        List<Integer> mediums = getMediums();
+        dest.writeInt(mediums.size());
+        for (int medium : mediums) {
+            dest.writeInt(medium);
+        }
+        dest.writeInt(getRssi());
+        dest.writeInt(mSalt.length);
+        dest.writeByteArray(mSalt);
+        dest.writeInt(mSecretId.length);
+        dest.writeByteArray(mSecretId);
+        dest.writeInt(mEncryptedIdentity.length);
+        dest.writeByteArray(mEncryptedIdentity);
+        dest.writeString(mDeviceId);
+        dest.writeInt(mDeviceType);
+        dest.writeInt(mDeviceImageUrl == null ? 0 : 1);
+        if (mDeviceImageUrl != null) {
+            dest.writeString(mDeviceImageUrl);
+        }
+        dest.writeLong(mDiscoveryTimestampMillis);
+        dest.writeInt(mExtendedProperties.size());
+        for (DataElement dataElement : mExtendedProperties) {
+            dest.writeParcelable(dataElement, 0);
+        }
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @NonNull
+    public static final Creator<PresenceDevice> CREATOR = new Creator<PresenceDevice>() {
+        @Override
+        public PresenceDevice createFromParcel(Parcel in) {
+            String name = null;
+            if (in.readInt() == 1) {
+                name = in.readString();
+            }
+            int size = in.readInt();
+            List<Integer> mediums = new ArrayList<>();
+            for (int i = 0; i < size; i++) {
+                mediums.add(in.readInt());
+            }
+            int rssi = in.readInt();
+            byte[] salt = new byte[in.readInt()];
+            in.readByteArray(salt);
+            byte[] secretId = new byte[in.readInt()];
+            in.readByteArray(secretId);
+            byte[] encryptedIdentity = new byte[in.readInt()];
+            in.readByteArray(encryptedIdentity);
+            String deviceId = in.readString();
+            int deviceType = in.readInt();
+            String deviceImageUrl = null;
+            if (in.readInt() == 1) {
+                deviceImageUrl = in.readString();
+            }
+            long discoveryTimeMillis = in.readLong();
+            int dataElementSize = in.readInt();
+            List<DataElement> dataElements = new ArrayList<>();
+            for (int i = 0; i < dataElementSize; i++) {
+                dataElements.add(
+                        in.readParcelable(DataElement.class.getClassLoader(), DataElement.class));
+            }
+            Builder builder = new Builder(deviceId, salt, secretId, encryptedIdentity)
+                    .setName(name)
+                    .setRssi(rssi)
+                    .setDeviceType(deviceType)
+                    .setDeviceImageUrl(deviceImageUrl)
+                    .setDiscoveryTimestampMillis(discoveryTimeMillis);
+            for (int i = 0; i < mediums.size(); i++) {
+                builder.addMedium(mediums.get(i));
+            }
+            for (int i = 0; i < dataElements.size(); i++) {
+                builder.addExtendedProperty(dataElements.get(i));
+            }
+            return builder.build();
+        }
+
+        @Override
+        public PresenceDevice[] newArray(int size) {
+            return new PresenceDevice[size];
+        }
+    };
+
+    /**
+     * Builder class for {@link PresenceDevice}.
+     */
+    public static final class Builder {
+
+        private final List<DataElement> mExtendedProperties;
+        private final List<Integer> mMediums;
+        private final String mDeviceId;
+        private final byte[] mSalt;
+        private final byte[] mSecretId;
+        private final byte[] mEncryptedIdentity;
+
+        private String mName;
+        private int mRssi;
+        private int mDeviceType;
+        private String mDeviceImageUrl;
+        private long mDiscoveryTimestampMillis;
+
+        /**
+         * Constructs a {@link Builder}.
+         *
+         * @param deviceId the identifier on the discovered Presence device
+         * @param salt a random salt used in the beacon from the Presence device.
+         * @param secretId a secret identifier used in the beacon from the Presence device.
+         * @param encryptedIdentity the identity associated with the Presence device.
+         */
+        public Builder(@NonNull String deviceId, @NonNull byte[] salt, @NonNull byte[] secretId,
+                @NonNull byte[] encryptedIdentity) {
+            mDeviceId = deviceId;
+            mSalt = salt;
+            mSecretId = secretId;
+            mEncryptedIdentity = encryptedIdentity;
+            mMediums = new ArrayList<>();
+            mExtendedProperties = new ArrayList<>();
+            mRssi = -127;
+        }
+
+        /**
+         * Sets the name of the Presence device.
+         *
+         * @param name Name of the Presence. Can be {@code null} if there is no name.
+         */
+        @NonNull
+        public Builder setName(@Nullable String name) {
+            mName = name;
+            return this;
+        }
+
+        /**
+         * Adds the medium over which the Presence device is discovered.
+         *
+         * @param medium The {@link Medium} over which the device is discovered.
+         */
+        @NonNull
+        public Builder addMedium(@Medium int medium) {
+            mMediums.add(medium);
+            return this;
+        }
+
+        /**
+         * Sets the RSSI on the discovered Presence device.
+         *
+         * @param rssi The received signal strength in dBm.
+         */
+        @NonNull
+        public Builder setRssi(int rssi) {
+            mRssi = rssi;
+            return this;
+        }
+
+        /**
+         * Sets the type of discovered Presence device.
+         *
+         * @param deviceType Type of the Presence device.
+         */
+        @NonNull
+        public Builder setDeviceType(@DeviceType int deviceType) {
+            mDeviceType = deviceType;
+            return this;
+        }
+
+
+        /**
+         * Sets the image url of the discovered Presence device.
+         *
+         * @param deviceImageUrl Url of the image for the Presence device.
+         */
+        @NonNull
+        public Builder setDeviceImageUrl(@Nullable String deviceImageUrl) {
+            mDeviceImageUrl = deviceImageUrl;
+            return this;
+        }
+
+
+        /**
+         * Sets discovery timestamp, the clock is based on elapsed time.
+         *
+         * @param discoveryTimestampMillis Timestamp when the presence device is discovered.
+         */
+        @NonNull
+        public Builder setDiscoveryTimestampMillis(long discoveryTimestampMillis) {
+            mDiscoveryTimestampMillis = discoveryTimestampMillis;
+            return this;
+        }
+
+
+        /**
+         * Adds an extended property of the discovered presence device.
+         *
+         * @param dataElement Data element of the extended property.
+         */
+        @NonNull
+        public Builder addExtendedProperty(@NonNull DataElement dataElement) {
+            Objects.requireNonNull(dataElement);
+            mExtendedProperties.add(dataElement);
+            return this;
+        }
+
+        /**
+         * Builds a Presence device.
+         */
+        @NonNull
+        public PresenceDevice build() {
+            return new PresenceDevice(mName, mMediums, mRssi, mDeviceId,
+                    mSalt, mSecretId, mEncryptedIdentity,
+                    mDeviceType,
+                    mDeviceImageUrl,
+                    mDiscoveryTimestampMillis, mExtendedProperties);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PresenceScanFilter.java b/nearby/framework/java/android/nearby/PresenceScanFilter.java
new file mode 100644
index 0000000..f0c3c06
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PresenceScanFilter.java
@@ -0,0 +1,211 @@
+/*
+ * 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 android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.ArraySet;
+
+import com.android.internal.util.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Filter for scanning a nearby presence device.
+ *
+ * @hide
+ */
+@SystemApi
+public final class PresenceScanFilter extends ScanFilter implements Parcelable {
+
+    private final List<PublicCredential> mCredentials;
+    private final List<Integer> mPresenceActions;
+    private final List<DataElement> mExtendedProperties;
+
+    /**
+     * A list of credentials to filter on.
+     */
+    @NonNull
+    public List<PublicCredential> getCredentials() {
+        return mCredentials;
+    }
+
+    /**
+     * A list of presence actions for matching.
+     */
+    @NonNull
+    public List<Integer> getPresenceActions() {
+        return mPresenceActions;
+    }
+
+    /**
+     * A bundle of extended properties for matching.
+     */
+    @NonNull
+    public List<DataElement> getExtendedProperties() {
+        return mExtendedProperties;
+    }
+
+    private PresenceScanFilter(int rssiThreshold, List<PublicCredential> credentials,
+            List<Integer> presenceActions, List<DataElement> extendedProperties) {
+        super(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE, rssiThreshold);
+        mCredentials = new ArrayList<>(credentials);
+        mPresenceActions = new ArrayList<>(presenceActions);
+        mExtendedProperties = extendedProperties;
+    }
+
+    private PresenceScanFilter(Parcel in) {
+        super(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE, in);
+        mCredentials = new ArrayList<>();
+        if (in.readInt() != 0) {
+            in.readParcelableList(mCredentials, PublicCredential.class.getClassLoader(),
+                    PublicCredential.class);
+        }
+        mPresenceActions = new ArrayList<>();
+        if (in.readInt() != 0) {
+            in.readList(mPresenceActions, Integer.class.getClassLoader(), Integer.class);
+        }
+        mExtendedProperties = new ArrayList<>();
+        if (in.readInt() != 0) {
+            in.readParcelableList(mExtendedProperties, DataElement.class.getClassLoader(),
+                    DataElement.class);
+        }
+    }
+
+    @NonNull
+    public static final Creator<PresenceScanFilter> CREATOR = new Creator<PresenceScanFilter>() {
+        @Override
+        public PresenceScanFilter createFromParcel(Parcel in) {
+            // Skip Scan Filter type as it's used for parent class.
+            in.readInt();
+            return createFromParcelBody(in);
+        }
+
+        @Override
+        public PresenceScanFilter[] newArray(int size) {
+            return new PresenceScanFilter[size];
+        }
+    };
+
+    /**
+     * Create a {@link PresenceScanFilter} from the parcel body. Scan Filter type is skipped.
+     */
+    static PresenceScanFilter createFromParcelBody(Parcel in) {
+        return new PresenceScanFilter(in);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        super.writeToParcel(dest, flags);
+        dest.writeInt(mCredentials.size());
+        if (!mCredentials.isEmpty()) {
+            dest.writeParcelableList(mCredentials, 0);
+        }
+        dest.writeInt(mPresenceActions.size());
+        if (!mPresenceActions.isEmpty()) {
+            dest.writeList(mPresenceActions);
+        }
+        dest.writeInt(mExtendedProperties.size());
+        if (!mExtendedProperties.isEmpty()) {
+            dest.writeList(mExtendedProperties);
+        }
+    }
+
+    /**
+     * Builder for {@link PresenceScanFilter}.
+     */
+    public static final class Builder {
+        private int mMaxPathLoss;
+        private final Set<PublicCredential> mCredentials;
+        private final Set<Integer> mPresenceIdentities;
+        private final Set<Integer> mPresenceActions;
+        private final List<DataElement> mExtendedProperties;
+
+        public Builder() {
+            mMaxPathLoss = 127;
+            mCredentials = new ArraySet<>();
+            mPresenceIdentities = new ArraySet<>();
+            mPresenceActions = new ArraySet<>();
+            mExtendedProperties = new ArrayList<>();
+        }
+
+        /**
+         * Sets the max path loss (in dBm) for the scan request. The path loss is the attenuation
+         * of radio energy between sender and receiver. Path loss here is defined as (TxPower -
+         * Rssi).
+         */
+        @NonNull
+        public Builder setMaxPathLoss(@IntRange(from = 0, to = 127) int maxPathLoss) {
+            mMaxPathLoss = maxPathLoss;
+            return this;
+        }
+
+        /**
+         * Adds a credential the scan filter is expected to match.
+         */
+
+        @NonNull
+        public Builder addCredential(@NonNull PublicCredential credential) {
+            Objects.requireNonNull(credential);
+            mCredentials.add(credential);
+            return this;
+        }
+
+        /**
+         * Adds a presence action for filtering, which is an action the discoverer could take
+         * when it receives the broadcast of a presence device.
+         */
+        @NonNull
+        public Builder addPresenceAction(@IntRange(from = 1, to = 255) int action) {
+            mPresenceActions.add(action);
+            return this;
+        }
+
+        /**
+         * Add an extended property for scan filtering.
+         */
+        @NonNull
+        public Builder addExtendedProperty(@NonNull DataElement dataElement) {
+            Objects.requireNonNull(dataElement);
+            mExtendedProperties.add(dataElement);
+            return this;
+        }
+
+        /**
+         * Builds the scan filter.
+         */
+        @NonNull
+        public PresenceScanFilter build() {
+            Preconditions.checkState(!mCredentials.isEmpty(), "credentials cannot be empty");
+            return new PresenceScanFilter(mMaxPathLoss,
+                    new ArrayList<>(mCredentials),
+                    new ArrayList<>(mPresenceActions),
+                    mExtendedProperties);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PrivateCredential.java b/nearby/framework/java/android/nearby/PrivateCredential.java
new file mode 100644
index 0000000..d915cc6
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PrivateCredential.java
@@ -0,0 +1,161 @@
+/*
+ * 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 android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.internal.util.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a private credential.
+ *
+ * @hide
+ */
+@SystemApi
+public final class PrivateCredential extends PresenceCredential implements Parcelable {
+
+    @NonNull
+    public static final Creator<PrivateCredential> CREATOR = new Creator<PrivateCredential>() {
+        @Override
+        public PrivateCredential createFromParcel(Parcel in) {
+            in.readInt(); // Skip the type as it's used by parent class only.
+            return createFromParcelBody(in);
+        }
+
+        @Override
+        public PrivateCredential[] newArray(int size) {
+            return new PrivateCredential[size];
+        }
+    };
+
+    private byte[] mMetadataEncryptionKey;
+    private String mDeviceName;
+
+    private PrivateCredential(Parcel in) {
+        super(CREDENTIAL_TYPE_PRIVATE, in);
+        mMetadataEncryptionKey = new byte[in.readInt()];
+        in.readByteArray(mMetadataEncryptionKey);
+        mDeviceName = in.readString();
+    }
+
+    private PrivateCredential(int identityType, byte[] secretId,
+            String deviceName, byte[] authenticityKey, List<CredentialElement> credentialElements,
+            byte[] metadataEncryptionKey) {
+        super(CREDENTIAL_TYPE_PRIVATE, identityType, secretId, authenticityKey,
+                credentialElements);
+        mDeviceName = deviceName;
+        mMetadataEncryptionKey = metadataEncryptionKey;
+    }
+
+    static PrivateCredential createFromParcelBody(Parcel in) {
+        return new PrivateCredential(in);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        super.writeToParcel(dest, flags);
+        dest.writeInt(mMetadataEncryptionKey.length);
+        dest.writeByteArray(mMetadataEncryptionKey);
+        dest.writeString(mDeviceName);
+    }
+
+    /**
+     * Returns the metadata encryption key associated with this credential.
+     */
+    @NonNull
+    public byte[] getMetadataEncryptionKey() {
+        return mMetadataEncryptionKey;
+    }
+
+    /**
+     * Returns the device name associated with this credential.
+     */
+    @NonNull
+    public String getDeviceName() {
+        return mDeviceName;
+    }
+
+    /**
+     * Builder class for {@link PresenceCredential}.
+     */
+    public static final class Builder {
+        private final List<CredentialElement> mCredentialElements;
+
+        private @IdentityType int mIdentityType;
+        private final byte[] mSecretId;
+        private final byte[] mAuthenticityKey;
+        private final byte[] mMetadataEncryptionKey;
+        private final String mDeviceName;
+
+        public Builder(@NonNull byte[] secretId, @NonNull byte[] authenticityKey,
+                @NonNull byte[] metadataEncryptionKey, @NonNull String deviceName) {
+            Preconditions.checkState(secretId != null && secretId.length > 0,
+                    "secret id cannot be empty");
+            Preconditions.checkState(authenticityKey != null && authenticityKey.length > 0,
+                    "authenticity key cannot be empty");
+            Preconditions.checkState(
+                    metadataEncryptionKey != null && metadataEncryptionKey.length > 0,
+                    "metadataEncryptionKey cannot be empty");
+            Preconditions.checkState(deviceName != null && deviceName.length() > 0,
+                    "deviceName cannot be empty");
+            mSecretId = secretId;
+            mAuthenticityKey = authenticityKey;
+            mMetadataEncryptionKey = metadataEncryptionKey;
+            mDeviceName = deviceName;
+            mCredentialElements = new ArrayList<>();
+        }
+
+        /**
+         * Sets the identity type for the presence credential.
+         */
+        @NonNull
+        public Builder setIdentityType(@IdentityType int identityType) {
+            mIdentityType = identityType;
+            return this;
+        }
+
+        /**
+         * Adds an element to the credential.
+         */
+        @NonNull
+        public Builder addCredentialElement(@NonNull CredentialElement credentialElement) {
+            mCredentialElements.add(credentialElement);
+            return this;
+        }
+
+        /**
+         * Builds the {@link PresenceCredential}.
+         */
+        @NonNull
+        public PrivateCredential build() {
+            return new PrivateCredential(mIdentityType, mSecretId, mDeviceName,
+                    mAuthenticityKey, mCredentialElements, mMetadataEncryptionKey);
+        }
+
+    }
+}
diff --git a/nearby/framework/java/android/nearby/PublicCredential.java b/nearby/framework/java/android/nearby/PublicCredential.java
new file mode 100644
index 0000000..5998d19
--- /dev/null
+++ b/nearby/framework/java/android/nearby/PublicCredential.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 android.nearby;
+
+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.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a public credential.
+ *
+ * @hide
+ */
+@SystemApi
+public final class PublicCredential extends PresenceCredential implements Parcelable {
+    @NonNull
+    public static final Creator<PublicCredential> CREATOR =
+            new Creator<PublicCredential>() {
+                @Override
+                public PublicCredential createFromParcel(Parcel in) {
+                    in.readInt(); // Skip the type as it's used by parent class only.
+                    return createFromParcelBody(in);
+                }
+
+                @Override
+                public PublicCredential[] newArray(int size) {
+                    return new PublicCredential[size];
+                }
+            };
+
+    private final byte[] mPublicKey;
+    private final byte[] mEncryptedMetadata;
+    private final byte[] mEncryptedMetadataKeyTag;
+
+    private PublicCredential(
+            int identityType,
+            byte[] secretId,
+            byte[] authenticityKey,
+            List<CredentialElement> credentialElements,
+            byte[] publicKey,
+            byte[] encryptedMetadata,
+            byte[] metadataEncryptionKeyTag) {
+        super(CREDENTIAL_TYPE_PUBLIC, identityType, secretId, authenticityKey, credentialElements);
+        mPublicKey = publicKey;
+        mEncryptedMetadata = encryptedMetadata;
+        mEncryptedMetadataKeyTag = metadataEncryptionKeyTag;
+    }
+
+    private PublicCredential(Parcel in) {
+        super(CREDENTIAL_TYPE_PUBLIC, in);
+        mPublicKey = new byte[in.readInt()];
+        in.readByteArray(mPublicKey);
+        mEncryptedMetadata = new byte[in.readInt()];
+        in.readByteArray(mEncryptedMetadata);
+        mEncryptedMetadataKeyTag = new byte[in.readInt()];
+        in.readByteArray(mEncryptedMetadataKeyTag);
+    }
+
+    static PublicCredential createFromParcelBody(Parcel in) {
+        return new PublicCredential(in);
+    }
+
+    /** Returns the public key associated with this credential. */
+    @NonNull
+    public byte[] getPublicKey() {
+        return mPublicKey;
+    }
+
+    /** Returns the encrypted metadata associated with this credential. */
+    @NonNull
+    public byte[] getEncryptedMetadata() {
+        return mEncryptedMetadata;
+    }
+
+    /** Returns the metadata encryption key tag associated with this credential. */
+    @NonNull
+    public byte[] getEncryptedMetadataKeyTag() {
+        return mEncryptedMetadataKeyTag;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj instanceof PublicCredential) {
+            PublicCredential that = (PublicCredential) obj;
+            return super.equals(obj)
+                    && Arrays.equals(mPublicKey, that.mPublicKey)
+                    && Arrays.equals(mEncryptedMetadata, that.mEncryptedMetadata)
+                    && Arrays.equals(mEncryptedMetadataKeyTag, that.mEncryptedMetadataKeyTag);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(
+                super.hashCode(),
+                Arrays.hashCode(mPublicKey),
+                Arrays.hashCode(mEncryptedMetadata),
+                Arrays.hashCode(mEncryptedMetadataKeyTag));
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        super.writeToParcel(dest, flags);
+        dest.writeInt(mPublicKey.length);
+        dest.writeByteArray(mPublicKey);
+        dest.writeInt(mEncryptedMetadata.length);
+        dest.writeByteArray(mEncryptedMetadata);
+        dest.writeInt(mEncryptedMetadataKeyTag.length);
+        dest.writeByteArray(mEncryptedMetadataKeyTag);
+    }
+
+    /** Builder class for {@link PresenceCredential}. */
+    public static final class Builder {
+        private final List<CredentialElement> mCredentialElements;
+
+        private @IdentityType int mIdentityType;
+        private final byte[] mSecretId;
+        private final byte[] mAuthenticityKey;
+        private final byte[] mPublicKey;
+        private final byte[] mEncryptedMetadata;
+        private final byte[] mEncryptedMetadataKeyTag;
+
+        public Builder(
+                @NonNull byte[] secretId,
+                @NonNull byte[] authenticityKey,
+                @NonNull byte[] publicKey,
+                @NonNull byte[] encryptedMetadata,
+                @NonNull byte[] encryptedMetadataKeyTag) {
+            Preconditions.checkState(
+                    secretId != null && secretId.length > 0, "secret id cannot be empty");
+            Preconditions.checkState(
+                    authenticityKey != null && authenticityKey.length > 0,
+                    "authenticity key cannot be empty");
+            Preconditions.checkState(
+                    publicKey != null && publicKey.length > 0, "publicKey cannot be empty");
+            Preconditions.checkState(
+                    encryptedMetadata != null && encryptedMetadata.length > 0,
+                    "encryptedMetadata cannot be empty");
+            Preconditions.checkState(
+                    encryptedMetadataKeyTag != null && encryptedMetadataKeyTag.length > 0,
+                    "encryptedMetadataKeyTag cannot be empty");
+
+            mSecretId = secretId;
+            mAuthenticityKey = authenticityKey;
+            mPublicKey = publicKey;
+            mEncryptedMetadata = encryptedMetadata;
+            mEncryptedMetadataKeyTag = encryptedMetadataKeyTag;
+            mCredentialElements = new ArrayList<>();
+        }
+
+        /** Sets the identity type for the presence credential. */
+        @NonNull
+        public Builder setIdentityType(@IdentityType int identityType) {
+            mIdentityType = identityType;
+            return this;
+        }
+
+        /** Adds an element to the credential. */
+        @NonNull
+        public Builder addCredentialElement(@NonNull CredentialElement credentialElement) {
+            Objects.requireNonNull(credentialElement);
+            mCredentialElements.add(credentialElement);
+            return this;
+        }
+
+        /** Builds the {@link PresenceCredential}. */
+        @NonNull
+        public PublicCredential build() {
+            return new PublicCredential(
+                    mIdentityType,
+                    mSecretId,
+                    mAuthenticityKey,
+                    mCredentialElements,
+                    mPublicKey,
+                    mEncryptedMetadata,
+                    mEncryptedMetadataKeyTag);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/ScanCallback.java b/nearby/framework/java/android/nearby/ScanCallback.java
new file mode 100644
index 0000000..1b1b4bc
--- /dev/null
+++ b/nearby/framework/java/android/nearby/ScanCallback.java
@@ -0,0 +1,54 @@
+/*
+ * 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 android.nearby;
+
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+
+/**
+ * Reports newly discovered devices.
+ * Note: The frequency of the callback is dependent on whether the caller
+ * is in the foreground or background. Foreground callbacks will occur
+ * as fast as the underlying medium supports, whereas background
+ * use cases will be rate limited to improve performance (ie, only on
+ * found/lost/significant changes).
+ *
+ * @hide
+ */
+@SystemApi
+public interface ScanCallback {
+    /**
+     * Reports a {@link NearbyDevice} being discovered.
+     *
+     * @param device {@link NearbyDevice} that is found.
+     */
+    void onDiscovered(@NonNull NearbyDevice device);
+
+    /**
+     * Reports a {@link NearbyDevice} information(distance, packet, and etc) changed.
+     *
+     * @param device {@link NearbyDevice} that has updates.
+     */
+    void onUpdated(@NonNull NearbyDevice device);
+
+    /**
+     * Reports a {@link NearbyDevice} is no longer within range.
+     *
+     * @param device {@link NearbyDevice} that is lost.
+     */
+    void onLost(@NonNull NearbyDevice device);
+}
diff --git a/nearby/framework/java/android/nearby/ScanFilter.java b/nearby/framework/java/android/nearby/ScanFilter.java
new file mode 100644
index 0000000..1409426
--- /dev/null
+++ b/nearby/framework/java/android/nearby/ScanFilter.java
@@ -0,0 +1,99 @@
+/*
+ * 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 android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+
+/**
+ * Filter for scanning a nearby device.
+ *
+ * @hide
+ */
+@SystemApi
+public abstract class ScanFilter {
+    /**
+     * Creates a {@link ScanFilter} from the parcel.
+     *
+     * @hide
+     */
+    public static ScanFilter createFromParcel(Parcel in) {
+        int type = in.readInt();
+        switch (type) {
+            // Currently, only Nearby Presence filtering is supported, in the future
+            // filtering other nearby specifications will be added.
+            case ScanRequest.SCAN_TYPE_NEARBY_PRESENCE:
+                return PresenceScanFilter.createFromParcelBody(in);
+            default:
+                throw new IllegalStateException(
+                        "Unexpected scan type (value " + type + ") in parcel.");
+        }
+    }
+
+    private final @ScanRequest.ScanType int mType;
+    private final int mMaxPathLoss;
+
+    /**
+     * Constructs a Scan Filter.
+     *
+     * @hide
+     */
+    ScanFilter(@ScanRequest.ScanType int type, @IntRange(from = 0, to = 127) int maxPathLoss) {
+        mType = type;
+        mMaxPathLoss = maxPathLoss;
+    }
+
+    /**
+     * Constructs a Scan Filter.
+     *
+     * @hide
+     */
+    ScanFilter(@ScanRequest.ScanType int type, Parcel in) {
+        mType = type;
+        mMaxPathLoss = in.readInt();
+    }
+
+    /**
+     * Returns the type of this scan filter.
+     */
+    public @ScanRequest.ScanType int getType() {
+        return mType;
+    }
+
+    /**
+     * Returns the maximum path loss (in dBm) of the received scan result. The path loss is the
+     * attenuation of radio energy between sender and receiver. Path loss here is defined as
+     * (TxPower - Rssi).
+     */
+    @IntRange(from = 0, to = 127)
+    public int getMaxPathLoss() {
+        return mMaxPathLoss;
+    }
+
+    /**
+     *
+     * Writes the scan filter to the parcel.
+     *
+     * @hide
+     */
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mType);
+        dest.writeInt(mMaxPathLoss);
+    }
+}
diff --git a/nearby/framework/java/android/nearby/ScanRequest.aidl b/nearby/framework/java/android/nearby/ScanRequest.aidl
new file mode 100644
index 0000000..438dfed
--- /dev/null
+++ b/nearby/framework/java/android/nearby/ScanRequest.aidl
@@ -0,0 +1,19 @@
+/*
+ * 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;
+
+parcelable ScanRequest;
diff --git a/nearby/framework/java/android/nearby/ScanRequest.java b/nearby/framework/java/android/nearby/ScanRequest.java
new file mode 100644
index 0000000..a90b72d
--- /dev/null
+++ b/nearby/framework/java/android/nearby/ScanRequest.java
@@ -0,0 +1,366 @@
+/*
+ * 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 android.nearby;
+
+import android.Manifest;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.WorkSource;
+
+import com.android.internal.util.Preconditions;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * An encapsulation of various parameters for requesting nearby scans.
+ *
+ * @hide
+ */
+@SystemApi
+public final class ScanRequest implements Parcelable {
+
+    /** Scan type for scanning devices using fast pair protocol. */
+    public static final int SCAN_TYPE_FAST_PAIR = 1;
+    /** Scan type for scanning devices using nearby share protocol. */
+    public static final int SCAN_TYPE_NEARBY_SHARE = 2;
+    /** Scan type for scanning devices using nearby presence protocol. */
+    public static final int SCAN_TYPE_NEARBY_PRESENCE = 3;
+    /** Scan type for scanning devices using exposure notification protocol. */
+    public static final int SCAN_TYPE_EXPOSURE_NOTIFICATION = 4;
+
+    /** Scan mode uses highest duty cycle. */
+    public static final int SCAN_MODE_LOW_LATENCY = 2;
+    /** Scan in balanced power mode.
+     *  Scan results are returned at a rate that provides a good trade-off between scan
+     *  frequency and power consumption.
+     */
+    public static final int SCAN_MODE_BALANCED = 1;
+    /** Perform scan in low power mode. This is the default scan mode. */
+    public static final int SCAN_MODE_LOW_POWER = 0;
+    /**
+     * A special scan mode. Applications using this scan mode will passively listen for other scan
+     * results without starting BLE scans themselves.
+     */
+    public static final int SCAN_MODE_NO_POWER = -1;
+    /**
+     * Used to read a ScanRequest from a Parcel.
+     */
+    @NonNull
+    public static final Creator<ScanRequest> CREATOR = new Creator<ScanRequest>() {
+        @Override
+        public ScanRequest createFromParcel(Parcel in) {
+            ScanRequest.Builder builder = new ScanRequest.Builder()
+                    .setScanType(in.readInt())
+                    .setScanMode(in.readInt())
+                    .setBleEnabled(in.readBoolean())
+                    .setWorkSource(in.readTypedObject(WorkSource.CREATOR));
+            for (int i = 0; i < in.readInt(); i++) {
+                builder.addScanFilter(ScanFilter.createFromParcel(in));
+            }
+            return builder.build();
+        }
+
+        @Override
+        public ScanRequest[] newArray(int size) {
+            return new ScanRequest[size];
+        }
+    };
+
+    private final @ScanType int mScanType;
+    private final @ScanMode int mScanMode;
+    private final boolean mBleEnabled;
+    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) {
+        mScanType = scanType;
+        mScanMode = scanMode;
+        mBleEnabled = bleEnabled;
+        mWorkSource = workSource;
+        mScanFilters = scanFilters;
+    }
+
+    /**
+     * Convert scan mode to readable string.
+     *
+     * @param scanMode Integer that may represent a{@link ScanMode}.
+     */
+    @NonNull
+    public static String scanModeToString(@ScanMode int scanMode) {
+        switch (scanMode) {
+            case SCAN_MODE_LOW_LATENCY:
+                return "SCAN_MODE_LOW_LATENCY";
+            case SCAN_MODE_BALANCED:
+                return "SCAN_MODE_BALANCED";
+            case SCAN_MODE_LOW_POWER:
+                return "SCAN_MODE_LOW_POWER";
+            case SCAN_MODE_NO_POWER:
+                return "SCAN_MODE_NO_POWER";
+            default:
+                return "SCAN_MODE_INVALID";
+        }
+    }
+
+    /**
+     * Returns true if an integer is a defined scan type.
+     */
+    public static boolean isValidScanType(@ScanType int scanType) {
+        return scanType == SCAN_TYPE_FAST_PAIR
+                || scanType == SCAN_TYPE_NEARBY_SHARE
+                || scanType == SCAN_TYPE_NEARBY_PRESENCE
+                || scanType == SCAN_TYPE_EXPOSURE_NOTIFICATION;
+    }
+
+    /**
+     * Returns true if an integer is a defined scan mode.
+     */
+    public static boolean isValidScanMode(@ScanMode int scanMode) {
+        return scanMode == SCAN_MODE_LOW_LATENCY
+                || scanMode == SCAN_MODE_BALANCED
+                || scanMode == SCAN_MODE_LOW_POWER
+                || scanMode == SCAN_MODE_NO_POWER;
+    }
+
+    /**
+     * Returns the scan type for this request.
+     */
+    public @ScanType int getScanType() {
+        return mScanType;
+    }
+
+    /**
+     * Returns the scan mode for this request.
+     */
+    public @ScanMode int getScanMode() {
+        return mScanMode;
+    }
+
+    /**
+     * Returns if Bluetooth Low Energy enabled for scanning.
+     */
+    public boolean isBleEnabled() {
+        return mBleEnabled;
+    }
+
+    /**
+     * Returns Scan Filters for this request.
+     */
+    @NonNull
+    public List<ScanFilter> getScanFilters() {
+        return mScanFilters;
+    }
+
+    /**
+     * Returns the work source used for power attribution of this request.
+     *
+     * @hide
+     */
+    @SystemApi
+    @NonNull
+    public WorkSource getWorkSource() {
+        return mWorkSource;
+    }
+
+    /**
+     * No special parcel contents.
+     */
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    /**
+     * Returns a string representation of this ScanRequest.
+     */
+    @Override
+    public String toString() {
+        StringBuilder stringBuilder = new StringBuilder();
+        stringBuilder.append("Request[")
+                .append("scanType=").append(mScanType);
+        stringBuilder.append(", scanMode=").append(scanModeToString(mScanMode));
+        stringBuilder.append(", enableBle=").append(mBleEnabled);
+        stringBuilder.append(", workSource=").append(mWorkSource);
+        stringBuilder.append(", scanFilters=").append(mScanFilters);
+        stringBuilder.append("]");
+        return stringBuilder.toString();
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mScanType);
+        dest.writeInt(mScanMode);
+        dest.writeBoolean(mBleEnabled);
+        dest.writeTypedObject(mWorkSource, /* parcelableFlags= */0);
+        dest.writeInt(mScanFilters.size());
+        for (int i = 0; i < mScanFilters.size(); ++i) {
+            mScanFilters.get(i).writeToParcel(dest, flags);
+        }
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof ScanRequest) {
+            ScanRequest otherRequest = (ScanRequest) other;
+            return mScanType == otherRequest.mScanType
+                    && (mScanMode == otherRequest.mScanMode)
+                    && (mBleEnabled == otherRequest.mBleEnabled)
+                    && (Objects.equals(mWorkSource, otherRequest.mWorkSource));
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mScanType, mScanMode, mBleEnabled, mWorkSource);
+    }
+
+    /** @hide **/
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({SCAN_TYPE_FAST_PAIR, SCAN_TYPE_NEARBY_SHARE, SCAN_TYPE_NEARBY_PRESENCE,
+            SCAN_TYPE_EXPOSURE_NOTIFICATION})
+    public @interface ScanType {
+    }
+
+    /** @hide **/
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({SCAN_MODE_LOW_LATENCY, SCAN_MODE_BALANCED,
+            SCAN_MODE_LOW_POWER,
+            SCAN_MODE_NO_POWER})
+    public @interface ScanMode {}
+
+    /** A builder class for {@link ScanRequest}. */
+    public static final class Builder {
+        private static final int INVALID_SCAN_TYPE = -1;
+        private @ScanType int mScanType;
+        private @ScanMode int mScanMode;
+
+        private boolean mBleEnabled;
+        private WorkSource mWorkSource;
+        private List<ScanFilter> mScanFilters;
+
+        /** Creates a new Builder with the given scan type. */
+        public Builder() {
+            mScanType = INVALID_SCAN_TYPE;
+            mBleEnabled = true;
+            mWorkSource = new WorkSource();
+            mScanFilters = new ArrayList<>();
+        }
+
+        /**
+         * Sets the scan type for the request. The scan type must be one of the SCAN_TYPE_ constants
+         * in {@link ScanRequest}.
+         *
+         * @param scanType The scan type for the request
+         */
+        @NonNull
+        public Builder setScanType(@ScanType int scanType) {
+            mScanType = scanType;
+            return this;
+        }
+
+        /**
+         * Sets the scan mode for the request. The scan type must be one of the SCAN_MODE_ constants
+         * in {@link ScanRequest}.
+         *
+         * @param scanMode The scan mode for the request
+         */
+        @NonNull
+        public Builder setScanMode(@ScanMode int scanMode) {
+            mScanMode = scanMode;
+            return this;
+        }
+
+        /**
+         * Sets if the ble is enabled for scanning.
+         *
+         * @param bleEnabled If the BluetoothLe is enabled in the device.
+         */
+        @NonNull
+        public Builder setBleEnabled(boolean bleEnabled) {
+            mBleEnabled = bleEnabled;
+            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.
+         *
+         * <p>Permission enforcement occurs when the resulting scan request is used, not when
+         * this method is invoked.
+         *
+         * @param workSource identifying the application(s) for which to blame for the scan.
+         * @hide
+         */
+        @RequiresPermission(Manifest.permission.UPDATE_DEVICE_STATS)
+        @NonNull
+        @SystemApi
+        public Builder setWorkSource(@Nullable WorkSource workSource) {
+            if (workSource == null) {
+                mWorkSource = new WorkSource();
+            } else {
+                mWorkSource = workSource;
+            }
+            return this;
+        }
+
+        /**
+         * Adds a scan filter to the request. Client can call this method multiple times to add
+         * more than one scan filter. Scan results that match any of these scan filters will
+         * be returned.
+         *
+         * <p>On devices with hardware support, scan filters can significantly improve the battery
+         * usage of Nearby scans.
+         *
+         * @param scanFilter Filter for scanning the request.
+         */
+        @NonNull
+        public Builder addScanFilter(@NonNull ScanFilter scanFilter) {
+            Objects.requireNonNull(scanFilter);
+            mScanFilters.add(scanFilter);
+            return this;
+        }
+
+        /**
+         * Builds a scan request from this builder.
+         *
+         * @return a new nearby scan request.
+         * @throws IllegalStateException if the scanType is not one of the SCAN_TYPE_ constants in
+         *                               {@link ScanRequest}.
+         */
+        @NonNull
+        public ScanRequest build() {
+            Preconditions.checkState(isValidScanType(mScanType),
+                    "invalid scan type : " + mScanType
+                            + ", scan type must be one of ScanRequest#SCAN_TYPE_");
+            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);
+        }
+    }
+}
diff --git a/nearby/framework/java/android/nearby/aidl/ByteArrayParcel.aidl b/nearby/framework/java/android/nearby/aidl/ByteArrayParcel.aidl
new file mode 100644
index 0000000..53c73bd
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/ByteArrayParcel.aidl
@@ -0,0 +1,25 @@
+/*
+ * 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 android.nearby.aidl;
+
+/**
+ * This is to support 2D byte arrays.
+ * {@hide}
+ */
+parcelable ByteArrayParcel {
+    byte[] byteArray;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairAccountDevicesMetadataRequestParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairAccountDevicesMetadataRequestParcel.aidl
new file mode 100644
index 0000000..fc3ba22
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairAccountDevicesMetadataRequestParcel.aidl
@@ -0,0 +1,29 @@
+/*
+ * 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 android.nearby.aidl;
+
+import android.accounts.Account;
+import android.nearby.aidl.ByteArrayParcel;
+
+/**
+ * Request details for Metadata of Fast Pair devices associated with an account.
+ * {@hide}
+ */
+parcelable FastPairAccountDevicesMetadataRequestParcel {
+    Account account;
+    ByteArrayParcel[] deviceAccountKeys;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairAccountKeyDeviceMetadataParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairAccountKeyDeviceMetadataParcel.aidl
new file mode 100644
index 0000000..8014323
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairAccountKeyDeviceMetadataParcel.aidl
@@ -0,0 +1,35 @@
+// 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 android.nearby.aidl;
+
+import android.nearby.aidl.FastPairDeviceMetadataParcel;
+import android.nearby.aidl.FastPairDiscoveryItemParcel;
+
+/**
+ * Metadata of a Fast Pair device associated with an account.
+ * {@hide}
+ */
+ // TODO(b/204780849): remove unnecessary fields and polish comments.
+parcelable FastPairAccountKeyDeviceMetadataParcel {
+    // Key of the Fast Pair device associated with the account.
+    byte[] deviceAccountKey;
+    // Hash function of device account key and public bluetooth address.
+    byte[] sha256DeviceAccountKeyPublicAddress;
+    // Fast Pair device metadata for the Fast Pair device.
+    FastPairDeviceMetadataParcel metadata;
+    // Fast Pair discovery item tied to both the Fast Pair device and the
+    // account.
+    FastPairDiscoveryItemParcel discoveryItem;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairAntispoofKeyDeviceMetadataParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairAntispoofKeyDeviceMetadataParcel.aidl
new file mode 100644
index 0000000..4fd4d4b
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairAntispoofKeyDeviceMetadataParcel.aidl
@@ -0,0 +1,31 @@
+// 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 android.nearby.aidl;
+
+import android.nearby.aidl.FastPairDeviceMetadataParcel;
+
+/**
+ * Metadata of a Fast Pair device keyed by AntispoofKey,
+ * Used by initial pairing without account association.
+ *
+ * {@hide}
+ */
+parcelable FastPairAntispoofKeyDeviceMetadataParcel {
+    // Anti-spoof public key.
+    byte[] antispoofPublicKey;
+
+    // Fast Pair device metadata for the Fast Pair device.
+    FastPairDeviceMetadataParcel deviceMetadata;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairAntispoofKeyDeviceMetadataRequestParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairAntispoofKeyDeviceMetadataRequestParcel.aidl
new file mode 100644
index 0000000..afdcf15
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairAntispoofKeyDeviceMetadataRequestParcel.aidl
@@ -0,0 +1,26 @@
+/*
+ * 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 android.nearby.aidl;
+
+/**
+ * Request details for metadata of a Fast Pair device keyed by either
+ * antispoofKey or modelId.
+ * {@hide}
+ */
+parcelable FastPairAntispoofKeyDeviceMetadataRequestParcel {
+    byte[] modelId;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairDeviceMetadataParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairDeviceMetadataParcel.aidl
new file mode 100644
index 0000000..ef00321
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairDeviceMetadataParcel.aidl
@@ -0,0 +1,130 @@
+/*
+ * 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 android.nearby.aidl;
+
+/**
+ * Fast Pair Device Metadata for a given device model ID.
+ * @hide
+ */
+// TODO(b/204780849): remove unnecessary fields and polish comments.
+parcelable FastPairDeviceMetadataParcel {
+    // The image to show on the notification.
+    String imageUrl;
+
+    // The intent that will be launched via the notification.
+    String intentUri;
+
+    // The transmit power of the device's BLE chip.
+    int bleTxPower;
+
+    // The distance that the device must be within to show a notification.
+    // If no distance is set, we default to 0.6 meters. Only Nearby admins can
+    // change this.
+    float triggerDistance;
+
+    // The image icon that shows in the notification.
+    byte[] image;
+
+    // The name of the device.
+    String name;
+
+    int deviceType;
+
+    // The image urls for device with device type "true wireless".
+    String trueWirelessImageUrlLeftBud;
+    String trueWirelessImageUrlRightBud;
+    String trueWirelessImageUrlCase;
+
+    // Stings to be displayed in notification surfaced for a device.
+    // The locale of all of the Strings.
+    String locale;
+
+    // The notification description for when the device is initially discovered.
+    String initialNotificationDescription;
+
+    // The notification description for when the device is initially discovered
+    // and no account is logged in.
+    String initialNotificationDescriptionNoAccount;
+
+    // The notification description for once we have finished pairing and the
+    // companion app has been opened. For Bisto devices, this String will point
+    // users to setting up the assistant.
+    String openCompanionAppDescription;
+
+    // The notification description for once we have finished pairing and the
+    // companion app needs to be updated before use.
+    String updateCompanionAppDescription;
+
+    // The notification description for once we have finished pairing and the
+    // companion app needs to be installed.
+    String downloadCompanionAppDescription;
+
+    // The notification title when a pairing fails.
+    String unableToConnectTitle;
+
+    // The notification summary when a pairing fails.
+    String unableToConnectDescription;
+
+    // The description that helps user initially paired with device.
+    String initialPairingDescription;
+
+    // The description that let user open the companion app.
+    String connectSuccessCompanionAppInstalled;
+
+    // The description that let user download the companion app.
+    String connectSuccessCompanionAppNotInstalled;
+
+    // The description that reminds user there is a paired device nearby.
+    String subsequentPairingDescription;
+
+    // The description that reminds users opt in their device.
+    String retroactivePairingDescription;
+
+    // The description that indicates companion app is about to launch.
+    String waitLaunchCompanionAppDescription;
+
+    // The description that indicates go to bluetooth settings when connection
+    // fail.
+    String failConnectGoToSettingsDescription;
+
+    // The title of the UI to ask the user to confirm the pin code.
+    String confirmPinTitle;
+
+    // The description of the UI to ask the user to confirm the pin code.
+    String confirmPinDescription;
+
+    // The title of the UI to ask the user to confirm to sync contacts.
+    String syncContactsTitle;
+
+    // The description of the UI to ask the user to confirm to sync contacts.
+    String syncContactsDescription;
+
+    // The title of the UI to ask the user to confirm to sync SMS.
+    String syncSmsTitle;
+
+    // The description of the UI to ask the user to confirm to sync SMS.
+    String syncSmsDescription;
+
+    // The description in half sheet to ask user setup google assistant
+    String assistantSetupHalfSheet;
+
+    // The description in notification to ask user setup google assistant
+    String assistantSetupNotification;
+
+    // Description of the connect device action on TV, when user is not logged in.
+    String fastPairTvConnectDeviceNoAccountDescription;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairDiscoveryItemParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairDiscoveryItemParcel.aidl
new file mode 100644
index 0000000..5ba18bb
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairDiscoveryItemParcel.aidl
@@ -0,0 +1,126 @@
+/*
+ * 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 android.nearby.aidl;
+
+/**
+ * Fast Pair Discovery Item.
+ * @hide
+ */
+// TODO(b/204780849): remove unnecessary fields and polish comments.
+parcelable FastPairDiscoveryItemParcel {
+  // Offline item: unique ID generated on client.
+  // Online item: unique ID generated on server.
+  String id;
+
+  int type;
+
+  // The most recent all upper case mac associated with this item.
+  // (Mac-to-DiscoveryItem is a many-to-many relationship)
+  String macAddress;
+
+  String actionUrl;
+
+  // The bluetooth device name from advertisement
+  String deviceName;
+
+  // Item's title
+  String title;
+
+  // Item's description.
+  String description;
+
+  // The URL for display
+  String displayUrl;
+
+  // Client timestamp when the beacon was last observed in BLE scan.
+  long lastObservationTimestampMillis;
+
+  // Client timestamp when the beacon was first observed in BLE scan.
+  long firstObservationTimestampMillis;
+
+  // Item's current state. e.g. if the item is blocked.
+  int state;
+
+  // The resolved url type for the action_url.
+  int actionUrlType;
+
+  // The timestamp when the user is redirected to Play Store after clicking on
+  // the item.
+  long pendingAppInstallTimestampMillis;
+
+  // Beacon's RSSI value
+  int rssi;
+
+  // Beacon's tx power
+  int txPower;
+
+  // Human readable name of the app designated to open the uri
+  // Used in the second line of the notification, "Open in {} app"
+  String appName;
+
+  // ID used for associating several DiscoveryItems.  These items may be
+  // visually displayed together.
+  String groupId;
+
+  // Whether the attachment is created in debug namespace
+  int attachmentType;
+
+  // Package name of the App that owns this item.
+  String packageName;
+
+  // The "feature" graphic image url used for large sized list view entries.
+  String featureGraphicUrl;
+
+  // TriggerId identifies the trigger/beacon that is attached with a message.
+  // It's generated from server for online messages to synchronize formatting
+  // across client versions.
+  // Example:
+  // * BLE_UID: 3||deadbeef
+  // * BLE_URL: http://trigger.id
+  // See go/discovery-store-message-and-trigger-id for more details.
+  String triggerId;
+
+  // Bytes of item icon in PNG format displayed in Discovery item list.
+  byte[] iconPng;
+
+  // A FIFE URL of the item icon displayed in Discovery item list.
+  String iconFifeUrl;
+
+  // Message written to bugreport for 3P developers.(No sensitive info)
+  // null if the item is valid
+  String debugMessage;
+
+  // Weather the item is filtered out on server.
+  int debugCategory;
+
+  // Client timestamp when the trigger (e.g. beacon) was last lost (e.g. when
+  // Messages told us the beacon's no longer nearby).
+  long lostMillis;
+
+  // The kind of experience the user last had with this (e.g. if they dismissed
+  // the notification, that's bad; but if they tapped it, that's good).
+  int lastUserExperience;
+
+  // The most recent BLE advertisement related to this item.
+  byte[] bleRecordBytes;
+
+  // An ID generated on the server to uniquely identify content.
+  String entityId;
+
+  // Fast Pair antispoof key.
+  byte[] authenticationPublicKeySecp256r1;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairEligibleAccountParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairEligibleAccountParcel.aidl
new file mode 100644
index 0000000..747758d
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairEligibleAccountParcel.aidl
@@ -0,0 +1,29 @@
+/*
+ * 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 android.nearby.aidl;
+
+import android.accounts.Account;
+
+/**
+ * Fast Pair Eligible Account.
+ * {@hide}
+ */
+parcelable FastPairEligibleAccountParcel {
+    Account account;
+    // Whether the account opts in Fast Pair.
+    boolean optIn;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairEligibleAccountsRequestParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairEligibleAccountsRequestParcel.aidl
new file mode 100644
index 0000000..8db3356
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairEligibleAccountsRequestParcel.aidl
@@ -0,0 +1,25 @@
+/*
+ * 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 android.nearby.aidl;
+
+/**
+ * Request details for Fast Pair eligible accounts.
+ * Empty place holder for future expansion.
+ * {@hide}
+ */
+parcelable FastPairEligibleAccountsRequestParcel {
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairManageAccountDeviceRequestParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairManageAccountDeviceRequestParcel.aidl
new file mode 100644
index 0000000..82cf550
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairManageAccountDeviceRequestParcel.aidl
@@ -0,0 +1,36 @@
+/*
+ * 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 android.nearby.aidl;
+
+import android.accounts.Account;
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+
+/**
+ * Request details for managing Fast Pair device-account mapping.
+ * {@hide}
+ */
+ // TODO(b/204780849): remove unnecessary fields and polish comments.
+parcelable FastPairManageAccountDeviceRequestParcel {
+    Account account;
+    // MANAGE_ACCOUNT_DEVICE_ADD: add Fast Pair device to the account.
+    // MANAGE_ACCOUNT_DEVICE_REMOVE: remove Fast Pair device from the account.
+    int requestType;
+    // Fast Pair account key-ed device metadata.
+    FastPairAccountKeyDeviceMetadataParcel accountKeyDeviceMetadata;
+    // BLE address of the device at the device add time.
+    String bleAddress;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/FastPairManageAccountRequestParcel.aidl b/nearby/framework/java/android/nearby/aidl/FastPairManageAccountRequestParcel.aidl
new file mode 100644
index 0000000..3d92064
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/FastPairManageAccountRequestParcel.aidl
@@ -0,0 +1,31 @@
+/*
+ * 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 android.nearby.aidl;
+
+import android.accounts.Account;
+
+/**
+ * Request details for managing a Fast Pair account.
+ *
+ * {@hide}
+ */
+parcelable FastPairManageAccountRequestParcel {
+    Account account;
+    // MANAGE_ACCOUNT_OPT_IN: opt account into Fast Pair.
+    // MANAGE_ACCOUNT_OPT_OUT: opt account out of Fast Pair.
+    int requestType;
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairAccountDevicesMetadataCallback.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairAccountDevicesMetadataCallback.aidl
new file mode 100644
index 0000000..7db18d0
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairAccountDevicesMetadataCallback.aidl
@@ -0,0 +1,28 @@
+// 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 android.nearby.aidl;
+
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+
+/**
+  * Provides callback interface for OEMs to send back metadata of FastPair
+  * devices associated with an account.
+  *
+  * {@hide}
+  */
+interface IFastPairAccountDevicesMetadataCallback {
+     void onFastPairAccountDevicesMetadataReceived(in FastPairAccountKeyDeviceMetadataParcel[] accountDevicesMetadata);
+     void onError(int code, String message);
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairAntispoofKeyDeviceMetadataCallback.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairAntispoofKeyDeviceMetadataCallback.aidl
new file mode 100644
index 0000000..38abba4
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairAntispoofKeyDeviceMetadataCallback.aidl
@@ -0,0 +1,27 @@
+// 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 android.nearby.aidl;
+
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataParcel;
+
+/**
+  * Provides callback interface for OEMs to send FastPair AntispoofKey Device metadata back.
+  *
+  * {@hide}
+  */
+interface IFastPairAntispoofKeyDeviceMetadataCallback {
+     void onFastPairAntispoofKeyDeviceMetadataReceived(in FastPairAntispoofKeyDeviceMetadataParcel metadata);
+     void onError(int code, String message);
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairClient.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairClient.aidl
new file mode 100644
index 0000000..4f666bc
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairClient.aidl
@@ -0,0 +1,35 @@
+/*
+ * 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.aidl;
+
+import android.nearby.aidl.IFastPairStatusCallback;
+import android.nearby.FastPairDevice;
+
+/**
+ * 0p API for controlling Fast Pair. Used to talk between foreground activities
+ * and background services.
+ *
+ * {@hide}
+ */
+interface IFastPairClient {
+
+    void registerHalfSheet(in IFastPairStatusCallback fastPairStatusCallback);
+
+    void unregisterHalfSheet(in IFastPairStatusCallback fastPairStatusCallback);
+
+    void connect(in FastPairDevice fastPairDevice);
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairDataProvider.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairDataProvider.aidl
new file mode 100644
index 0000000..2956211
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairDataProvider.aidl
@@ -0,0 +1,44 @@
+// 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 android.nearby.aidl;
+
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataRequestParcel;
+import android.nearby.aidl.IFastPairAntispoofKeyDeviceMetadataCallback;
+import android.nearby.aidl.FastPairAccountDevicesMetadataRequestParcel;
+import android.nearby.aidl.IFastPairAccountDevicesMetadataCallback;
+import android.nearby.aidl.FastPairEligibleAccountsRequestParcel;
+import android.nearby.aidl.IFastPairEligibleAccountsCallback;
+import android.nearby.aidl.FastPairManageAccountRequestParcel;
+import android.nearby.aidl.IFastPairManageAccountCallback;
+import android.nearby.aidl.FastPairManageAccountDeviceRequestParcel;
+import android.nearby.aidl.IFastPairManageAccountDeviceCallback;
+
+/**
+ * Interface for communicating with the fast pair providers.
+ *
+ * {@hide}
+ */
+oneway interface IFastPairDataProvider {
+    void loadFastPairAntispoofKeyDeviceMetadata(in FastPairAntispoofKeyDeviceMetadataRequestParcel request,
+        in IFastPairAntispoofKeyDeviceMetadataCallback callback);
+    void loadFastPairAccountDevicesMetadata(in FastPairAccountDevicesMetadataRequestParcel request,
+        in IFastPairAccountDevicesMetadataCallback callback);
+    void loadFastPairEligibleAccounts(in FastPairEligibleAccountsRequestParcel request,
+        in IFastPairEligibleAccountsCallback callback);
+    void manageFastPairAccount(in FastPairManageAccountRequestParcel request,
+        in IFastPairManageAccountCallback callback);
+    void manageFastPairAccountDevice(in FastPairManageAccountDeviceRequestParcel request,
+        in IFastPairManageAccountDeviceCallback callback);
+}
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairEligibleAccountsCallback.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairEligibleAccountsCallback.aidl
new file mode 100644
index 0000000..9990014
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairEligibleAccountsCallback.aidl
@@ -0,0 +1,28 @@
+// 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 android.nearby.aidl;
+
+import android.accounts.Account;
+import android.nearby.aidl.FastPairEligibleAccountParcel;
+
+/**
+  * Provides callback interface for OEMs to return FastPair Eligible accounts.
+  *
+  * {@hide}
+  */
+interface IFastPairEligibleAccountsCallback {
+     void onFastPairEligibleAccountsReceived(in FastPairEligibleAccountParcel[] accounts);
+     void onError(int code, String message);
+ }
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairManageAccountCallback.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairManageAccountCallback.aidl
new file mode 100644
index 0000000..6b4aaee
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairManageAccountCallback.aidl
@@ -0,0 +1,25 @@
+// 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 android.nearby.aidl;
+
+/**
+  * Provides callback interface to send response for account management request.
+  *
+  * {@hide}
+  */
+interface IFastPairManageAccountCallback {
+     void onSuccess();
+     void onError(int code, String message);
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairManageAccountDeviceCallback.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairManageAccountDeviceCallback.aidl
new file mode 100644
index 0000000..bffc533
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairManageAccountDeviceCallback.aidl
@@ -0,0 +1,26 @@
+// 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 android.nearby.aidl;
+
+/**
+  * Provides callback interface to send response for account-device mapping
+  * management request.
+  *
+  * {@hide}
+  */
+interface IFastPairManageAccountDeviceCallback {
+     void onSuccess();
+     void onError(int code, String message);
+}
\ No newline at end of file
diff --git a/nearby/framework/java/android/nearby/aidl/IFastPairStatusCallback.aidl b/nearby/framework/java/android/nearby/aidl/IFastPairStatusCallback.aidl
new file mode 100644
index 0000000..d844c06
--- /dev/null
+++ b/nearby/framework/java/android/nearby/aidl/IFastPairStatusCallback.aidl
@@ -0,0 +1,32 @@
+/*
+ * 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.aidl;
+
+import android.nearby.FastPairDevice;
+import android.nearby.PairStatusMetadata;
+
+/**
+ *
+ * Provides callbacks for Fast Pair foreground activity to learn about paring status from backend.
+ *
+ * {@hide}
+ */
+interface IFastPairStatusCallback {
+
+    /** Reports a pair status related metadata associated with a {@link FastPairDevice} */
+    void onPairUpdate(in FastPairDevice fastPairDevice, in PairStatusMetadata pairStatusMetadata);
+}
diff --git a/nearby/halfsheet/Android.bp b/nearby/halfsheet/Android.bp
new file mode 100644
index 0000000..486a3ff
--- /dev/null
+++ b/nearby/halfsheet/Android.bp
@@ -0,0 +1,57 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+    name: "HalfSheetUX",
+    defaults: ["platform_app_defaults"],
+    srcs: ["src/**/*.java"],
+    sdk_version: "module_current",
+    // This is included in tethering apex, which uses min SDK 30
+    min_sdk_version: "30",
+    target_sdk_version: "current",
+    updatable: true,
+    certificate: ":com.android.nearby.halfsheetcertificate",
+    libs: [
+        "framework-bluetooth",
+        "framework-connectivity-t",
+        "nearby-service-string",
+    ],
+    static_libs: [
+        "androidx.annotation_annotation",
+        "androidx.fragment_fragment",
+        "androidx-constraintlayout_constraintlayout",
+        "androidx.localbroadcastmanager_localbroadcastmanager",
+        "androidx.core_core",
+        "androidx.appcompat_appcompat",
+        "androidx.recyclerview_recyclerview",
+        "androidx.lifecycle_lifecycle-runtime",
+        "androidx.lifecycle_lifecycle-extensions",
+        "com.google.android.material_material",
+        "fast-pair-lite-protos",
+    ],
+    plugins: ["java_api_finder"],
+    manifest: "AndroidManifest.xml",
+    jarjar_rules: ":nearby-jarjar-rules",
+    apex_available: ["com.android.tethering",],
+    lint: { strict_updatability_linting: true }
+}
+
+android_app_certificate {
+    name: "com.android.nearby.halfsheetcertificate",
+    certificate: "apk-certs/com.android.nearby.halfsheet"
+}
diff --git a/nearby/halfsheet/AndroidManifest.xml b/nearby/halfsheet/AndroidManifest.xml
new file mode 100644
index 0000000..22987fb
--- /dev/null
+++ b/nearby/halfsheet/AndroidManifest.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.nearby.halfsheet">
+    <application>
+        <activity
+            android:name="com.android.nearby.halfsheet.HalfSheetActivity"
+            android:exported="true"
+            android:launchMode="singleInstance"
+            android:theme="@style/HalfSheetStyle" >
+            <intent-filter>
+                <action android:name="android.nearby.SHOW_HALFSHEET"/>
+                <category android:name="android.intent.category.DEFAULT"/>
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/nearby/halfsheet/apk-certs/com.android.nearby.halfsheet.pk8 b/nearby/halfsheet/apk-certs/com.android.nearby.halfsheet.pk8
new file mode 100644
index 0000000..187d51e
--- /dev/null
+++ b/nearby/halfsheet/apk-certs/com.android.nearby.halfsheet.pk8
Binary files differ
diff --git a/nearby/halfsheet/apk-certs/com.android.nearby.halfsheet.x509.pem b/nearby/halfsheet/apk-certs/com.android.nearby.halfsheet.x509.pem
new file mode 100644
index 0000000..440c524
--- /dev/null
+++ b/nearby/halfsheet/apk-certs/com.android.nearby.halfsheet.x509.pem
@@ -0,0 +1,34 @@
+-----BEGIN CERTIFICATE-----
+MIIF6zCCA9OgAwIBAgIUU5ATKevcNA5ZSurwgwGenwrr4c4wDQYJKoZIhvcNAQEL
+BQAwgYMxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMQwwCgYDVQQH
+DANNVFYxDzANBgNVBAoMBkdvb2dsZTEPMA0GA1UECwwGbmVhcmJ5MQswCQYDVQQD
+DAJ3czEiMCAGCSqGSIb3DQEJARYTd2VpY2VzdW5AZ29vZ2xlLmNvbTAgFw0yMTEy
+MDgwMTMxMzFaGA80NzU5MTEwNDAxMzEzMVowgYMxCzAJBgNVBAYTAlVTMRMwEQYD
+VQQIDApDYWxpZm9ybmlhMQwwCgYDVQQHDANNVFYxDzANBgNVBAoMBkdvb2dsZTEP
+MA0GA1UECwwGbmVhcmJ5MQswCQYDVQQDDAJ3czEiMCAGCSqGSIb3DQEJARYTd2Vp
+Y2VzdW5AZ29vZ2xlLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
+AO0JW1YZ5bKHZG5B9eputz3kGREmXcWZ97dg/ODDs3+op4ulBmgaYeo5yeCy29GI
+Sjgxo4G+9fNZ7Fejrk5/LLWovAoRvVxnkRxCkTfp15jZpKNnZjT2iTRLXzNz2O04
+cC0jB81mu5vJ9a8pt+EQkuSwjDMiUi6q4Sf6IRxtTCd5a1yn9eHf1y2BbCmU+Eys
+bs97HJl9PgMCp7hP+dYDxEtNTAESg5IpJ1i7uINgPNl8d0tvJ9rOEdy0IcdeGwt/
+t0L9fIoRCePttH+idKIyDjcNyp9WtX2/wZKlsGap83rGzLdL2PI4DYJ2Ytmy8W3a
+9qFJNrhl3Q3BYgPlcCg9qQOIKq6ZJgFFH3snVDKvtSFd8b9ofK7UzD5g2SllTqDA
+4YvrdK4GETQunSjG7AC/2PpvN/FdhHm7pBi0fkgwykMh35gv0h8mmb6pBISYgr85
++GMBilNiNJ4G6j3cdOa72pvfDW5qn5dn5ks8cIgW2X1uF/GT8rR6Mb2rwhjY9eXk
+TaP0RykyzheMY/7dWeA/PdN3uMCEJEt72ZakDIswgQVPCIw8KQPIf6pl0d5hcLSV
+QzhqBaXudseVg0QlZ86iaobpZvCrW0KqQmMU5GVhEtDc2sPe5e+TCmUC/H+vo8F8
+1UYu3MJaBcpePFlgIsLhW0niUTfCq2FiNrPykOJT7U9NAgMBAAGjUzBRMB0GA1Ud
+DgQWBBQKSepRcKTv9hr8mmKjYCL7NeG2izAfBgNVHSMEGDAWgBQKSepRcKTv9hr8
+mmKjYCL7NeG2izAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQC/
+BoItafzvjYPzENY16BIkgRqJVU7IosWxGLczzg19NFu6HPa54alqkawp7RI1ZNVH
+bJjQma5ap0L+Y06/peIU9rvEtfbCkkYJwvIaSRlTlzrNwNEcj3yJMmGTr/wfIzq8
+PN1t0hihnqI8ZguOPC+sV6ARoC+ygkwaLU1oPbVvOGz9WplvSokE1mvtqKAyuDoL
+LZfWwbhxRAgwgCIEz6cPfEcgg3Xzc+L4OzmNhTTc7GNOAtvvW7Zqc2Lohb8nQMNw
+uY65yiHPNmjmc+xLHZk3jQg82tKv792JJRkVXPsIfQV087IzxFFjjvKy82rVfeaN
+F9g2EpUvdjtm8zx7K5tiDv9Es/Up7oOnoB5baLgnMAEVMTZY+4k/6BfVM5CVUu+H
+AO1yh2yeNWbzY8B+zxRef3C2Ax68lJHFyz8J1pfrGpWxML3rDmWiVDMtEk73t3g+
+lcyLYo7OW+iBn6BODRcINO4R640oyMjFz2wPSPAsU0Zj/MbgC6iaS+goS3QnyPQS
+O3hKWfwqQuA7BZ0la1n+plKH5PKxQESAbd37arzCsgQuktl33ONiwYOt6eUyHl/S
+E3ZdldkmGm9z0mcBYG9NczDBSYmtuZOGjEzIRqI5GFD2WixE+dqTzVP/kyBd4BLc
+OTmBynN/8D/qdUZNrT+tgs+mH/I2SsKYW9Zymwf7Qw==
+-----END CERTIFICATE-----
diff --git a/nearby/halfsheet/apk-certs/key.pem b/nearby/halfsheet/apk-certs/key.pem
new file mode 100644
index 0000000..e9f4288
--- /dev/null
+++ b/nearby/halfsheet/apk-certs/key.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDtCVtWGeWyh2Ru
+QfXqbrc95BkRJl3Fmfe3YPzgw7N/qKeLpQZoGmHqOcngstvRiEo4MaOBvvXzWexX
+o65Ofyy1qLwKEb1cZ5EcQpE36deY2aSjZ2Y09ok0S18zc9jtOHAtIwfNZrubyfWv
+KbfhEJLksIwzIlIuquEn+iEcbUwneWtcp/Xh39ctgWwplPhMrG7PexyZfT4DAqe4
+T/nWA8RLTUwBEoOSKSdYu7iDYDzZfHdLbyfazhHctCHHXhsLf7dC/XyKEQnj7bR/
+onSiMg43DcqfVrV9v8GSpbBmqfN6xsy3S9jyOA2CdmLZsvFt2vahSTa4Zd0NwWID
+5XAoPakDiCqumSYBRR97J1Qyr7UhXfG/aHyu1Mw+YNkpZU6gwOGL63SuBhE0Lp0o
+xuwAv9j6bzfxXYR5u6QYtH5IMMpDId+YL9IfJpm+qQSEmIK/OfhjAYpTYjSeBuo9
+3HTmu9qb3w1uap+XZ+ZLPHCIFtl9bhfxk/K0ejG9q8IY2PXl5E2j9EcpMs4XjGP+
+3VngPz3Td7jAhCRLe9mWpAyLMIEFTwiMPCkDyH+qZdHeYXC0lUM4agWl7nbHlYNE
+JWfOomqG6Wbwq1tCqkJjFORlYRLQ3NrD3uXvkwplAvx/r6PBfNVGLtzCWgXKXjxZ
+YCLC4VtJ4lE3wqthYjaz8pDiU+1PTQIDAQABAoICAQCt4R5CM+8enlka1IIbvann
+2cpVnUpOaNqhh6EZFBY5gDOfqafgd/H5yvh/P1UnCI5BWJBz3ew33nAT/fsglAPt
+ImEGFetNvJ9jFqXGWWCRPJ6cS35bPbp6RQwKB2JK6grH4ZmYoFLhPi5elwDPNcQ7
+xBKkc/nLSAiwtbjSTI7/qf8K0h752aTUOctpWWEnhZon00ywf4Ic3TbBatF/n/W/
+s20coEMp1cyKN/JrVQ5uD/LGwDyBModB2lWpFSxLrB14I9DWyxbxP28X7ckXLhbl
+ZdWMOyQZoa/S7n5PYT49g1Wq5BW54UpvuH5c6fpWtrgSqk1cyUR2EbTf3NAAhPLU
+PgPK8wbFMcMB3TpQDXl7USA7QX5wSv22OfhivPsHQ9szGM0f84mK0PhXYPWBiNUY
+Y8rrIjOijB4eFGDFnTIMTofAb07NxRThci710BYUqgBVTBG5N+avIesjwkikMjOI
+PwYukKSQSw/Tqxy5Z9l22xksGynBZFjEFs/WT5pDczPAktA4xW3CGxjkMsIYaOBs
+OCEujqc5+mHSywYvy8aN+nA+yPucJP5e5pLZ1qaU0tqyakCx8XeeOyP6Wfm3UAAV
+AYelBRcWcJxM51w4o5UnUnpBD+Uxiz1sRVlqa9bLJjP4M+wJNL+WaIn9D6WhPOvl
++naDC+p29ou2JzyKFDsOQQKCAQEA+Jalm+xAAPc+t/gCdAqEDo0NMA2/NG8m9BRc
+CVZRRaWVyGPeg5ziT/7caGwy2jpOZEjK0OOTCAqF+sJRDj6DDIw7nDrlxNyaXnCF
+gguQHFIYaHcjKGTs5l0vgL3H7pMFHN2qVynf4xrTuBXyT1GJ4vdWKAJbooa02c8W
+XI2fjwZ7Y8wSWrm1tn3oTTBR3N6o1GyPY6/TrL0mhpWwgx5eJeLl3GuUxOhXY5R9
+y48ziS97Dqdq75MxUOHickofCNcm7p+jA8Hg+SxLMR/kUFsXOxawmvsBqdL1XzU5
+LTS7xAEY9iMuBcO6yIxcxqBx96idjsPXx1lgARo1CpaZYCzgPQKCAQEA9BqKMN/Y
+o+T+ac99St8x3TYkk5lkvLVqlPw+EQhEqrm9EEBPntxWM5FEIpPVmFm7taGTgPfN
+KKaaNxX5XyK9B2v1QqN7XrX0nF4+6x7ao64fdpRUParIuBVctqzQWWthme66eHrf
+L86T/tkt3o/7p+Hd4Z9UT3FaAew1ggWr00xz5PJ/4b3f3mRmtNmgeTYskWMxOpSj
+bEenom4Row7sfLNeXNSWDGlzJ/lf6svvbVM2X5h2uFsxlt/Frq9ooTA3wwhnbd1i
+cFifDQ6cxF5mBpz/V/hnlHVfuXlknEZa9EQXHNo/aC9y+bR+ai05FJyK/WgqleW8
+5PBmoTReWA2MUQKCAQAnnnLkh+GnhcBEN83ESszDOO3KI9a+d5yguAH3Jv+q9voJ
+Rwl2tnFHSJo+NkhgiXxm9UcFxc9wL6Us0v1yJLpkLJFvk9984Z/kv1A36rncGaV0
+ONCspnEvQdjJTvXnax0cfaOhYrYhDuyBYVYOGDO+rabYl4+dNpTqRdwNgjDU7baK
+sEKYnRJ99FEqxDG33vDPckHkJGi7FiZmusK4EwX0SdZSq/6450LORyNJZxhSm/Oj
+4UDkz/PDLU0W5ANQOGInE+A6QBMoA0w0lx2fRPVN4I7jFHAubcXXl7b2InpugbJF
+wFOcbZZ+UgiTS4z+aKw7zbC9P9xSMKgVeO0W6/ANAoIBABe0LA8q7YKczgfAWk5W
+9iShCVQ75QheJYdqJyzIPMLHXpChbhnjE4vWY2NoL6mnrQ6qLgSsC4QTCY6n15th
+aDG8Tgi2j1hXGvXEQR/b0ydp1SxSowuJ9gvKJ0Kl7WWBg+zKvdjNNbcSvFRXCpk+
+KhXXXRB3xFwiibb+FQQXQOQ33FkzIy/snDygS0jsiSS8Gf/UPgeOP4BYRPME9Tl8
+TYKeeF9TVW7HHqOXF7VZMFrRZcpKp9ynHl2kRTH9Xo+oewG5YzHL+a8nK+q8rIR1
+Fjs2K6WDPauw6ia8nwR94H8vzX7Dwrx/Pw74c/4jfhN+UBDjeJ8tu/YPUif9SdwL
+FMECggEALdCGKfQ4vPmqI6UdfVB5hdCPoM6tUsI2yrXFvlHjSGVanC/IG9x2mpRb
+4odamLYx4G4NjP1IJSY08LFT9VhLZtRM1W3fGeboW12LTEVNrI3lRBU84rAQ1ced
+l6/DvTKJjhfwTxb/W7sqmZY5hF3QuNxs67Z8x0pe4b58musa0qFCs4Sa8qTNZKRW
+fIbxIKuvu1HSNOKkZLu6Gq8km+XIlVAaSVA03Tt+EK74MFL6+pcd7/VkS00MAYUC
+gS4ic+QFzCl5P8zl/GoX8iUFsRZQCSJkZ75VwO13pEupVwCAW8WWJO83U4jBsnJs
+ayrX7pbsnW6jsNYBUlck+RYVYkVkxA==
+-----END PRIVATE KEY-----
diff --git a/nearby/halfsheet/res/anim/fast_pair_bottom_sheet_enter.xml b/nearby/halfsheet/res/anim/fast_pair_bottom_sheet_enter.xml
new file mode 100644
index 0000000..098dccb
--- /dev/null
+++ b/nearby/halfsheet/res/anim/fast_pair_bottom_sheet_enter.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+     android:interpolator="@android:interpolator/decelerate_quint">
+    <translate android:fromYDelta="100%"
+               android:toYDelta="0"
+               android:duration="900"/>
+</set>
diff --git a/nearby/halfsheet/res/anim/fast_pair_bottom_sheet_exit.xml b/nearby/halfsheet/res/anim/fast_pair_bottom_sheet_exit.xml
new file mode 100644
index 0000000..1cf7401
--- /dev/null
+++ b/nearby/halfsheet/res/anim/fast_pair_bottom_sheet_exit.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+     android:interpolator="@android:interpolator/decelerate_quint">
+    <translate android:fromYDelta="0"
+               android:toYDelta="100%"
+               android:duration="500"/>
+</set>
diff --git a/nearby/halfsheet/res/anim/fast_pair_half_sheet_slide_in.xml b/nearby/halfsheet/res/anim/fast_pair_half_sheet_slide_in.xml
new file mode 100644
index 0000000..9a51ddb
--- /dev/null
+++ b/nearby/halfsheet/res/anim/fast_pair_half_sheet_slide_in.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:targetApi="23"
+    android:duration="@integer/half_sheet_slide_in_duration"
+    android:interpolator="@android:interpolator/fast_out_slow_in">
+  <translate
+      android:fromYDelta="100%p"
+      android:toYDelta="0%p"/>
+
+  <alpha
+      android:fromAlpha="0.0"
+      android:toAlpha="1.0"/>
+</set>
diff --git a/nearby/halfsheet/res/anim/fast_pair_half_sheet_slide_out.xml b/nearby/halfsheet/res/anim/fast_pair_half_sheet_slide_out.xml
new file mode 100644
index 0000000..c589482
--- /dev/null
+++ b/nearby/halfsheet/res/anim/fast_pair_half_sheet_slide_out.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:duration="@integer/half_sheet_fade_out_duration"
+    android:interpolator="@android:interpolator/fast_out_slow_in">
+
+  <translate
+      android:fromYDelta="0%p"
+      android:toYDelta="100%p"/>
+
+  <alpha
+      android:fromAlpha="1.0"
+      android:toAlpha="0.0"/>
+
+</set>
diff --git a/nearby/halfsheet/res/drawable/fast_pair_ic_info.xml b/nearby/halfsheet/res/drawable/fast_pair_ic_info.xml
new file mode 100644
index 0000000..7d61d1c
--- /dev/null
+++ b/nearby/halfsheet/res/drawable/fast_pair_ic_info.xml
@@ -0,0 +1,25 @@
+<!--
+  ~ 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.
+  -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24.0"
+    android:viewportHeight="24.0"
+    android:tint="@color/fast_pair_half_sheet_subtitle_color">
+    <path
+        android:fillColor="@color/fast_pair_half_sheet_subtitle_color"
+        android:pathData="M11,17h2v-6h-2v6zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM11,9h2L13,7h-2v2z"/>
+</vector>
\ No newline at end of file
diff --git a/nearby/halfsheet/res/drawable/fastpair_outline.xml b/nearby/halfsheet/res/drawable/fastpair_outline.xml
new file mode 100644
index 0000000..6765e11
--- /dev/null
+++ b/nearby/halfsheet/res/drawable/fastpair_outline.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+  <stroke
+      android:width="1dp"
+      android:color="@color/fast_pair_notification_image_outline"/>
+</shape>
diff --git a/nearby/halfsheet/res/drawable/half_sheet_bg.xml b/nearby/halfsheet/res/drawable/half_sheet_bg.xml
new file mode 100644
index 0000000..7e7d8dd
--- /dev/null
+++ b/nearby/halfsheet/res/drawable/half_sheet_bg.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:targetApi="23">
+  <solid android:color="@color/fast_pair_half_sheet_background" />
+  <corners
+      android:topLeftRadius="16dp"
+      android:topRightRadius="16dp"
+      android:padding="8dp"/>
+</shape>
diff --git a/nearby/halfsheet/res/layout/fast_pair_device_pairing_fragment.xml b/nearby/halfsheet/res/layout/fast_pair_device_pairing_fragment.xml
new file mode 100644
index 0000000..7fbe229
--- /dev/null
+++ b/nearby/halfsheet/res/layout/fast_pair_device_pairing_fragment.xml
@@ -0,0 +1,139 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:orientation="vertical"
+    tools:ignore="RtlCompat"
+    android:layout_width="match_parent" android:layout_height="match_parent">
+
+  <androidx.constraintlayout.widget.ConstraintLayout
+      android:id="@+id/image_view"
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:minHeight="340dp"
+      android:paddingStart="12dp"
+      android:paddingEnd="12dp"
+      android:paddingTop="12dp"
+      android:paddingBottom="12dp">
+    <TextView
+        android:id="@+id/header_subtitle"
+        android:textColor="@color/fast_pair_half_sheet_subtitle_color"
+        android:fontFamily="google-sans"
+        android:textSize="14sp"
+        android:maxLines="3"
+        android:gravity="center"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+
+    <ImageView
+        android:id="@+id/pairing_pic"
+        android:layout_width="@dimen/fast_pair_half_sheet_image_size"
+        android:layout_height="@dimen/fast_pair_half_sheet_image_size"
+        android:paddingTop="18dp"
+        android:paddingBottom="18dp"
+        android:importantForAccessibility="no"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/header_subtitle" />
+
+    <TextView
+        android:id="@+id/pin_code"
+        android:textColor="@color/fast_pair_half_sheet_subtitle_color"
+        android:layout_width="wrap_content"
+        android:layout_height="@dimen/fast_pair_half_sheet_image_size"
+        android:paddingTop="18dp"
+        android:paddingBottom="18dp"
+        android:visibility="invisible"
+        android:textSize="50sp"
+        android:letterSpacing="0.2"
+        android:fontFamily="google-sans-medium"
+        android:gravity="center"
+        android:importantForAccessibility="yes"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/header_subtitle" />
+
+    <ProgressBar
+        android:id="@+id/connect_progressbar"
+        android:layout_width="@dimen/fast_pair_half_sheet_image_size"
+        android:layout_height="2dp"
+        android:indeterminate="true"
+        android:indeterminateTint="@color/fast_pair_progress_color"
+        android:indeterminateTintMode="src_in"
+        style="?android:attr/progressBarStyleHorizontal"
+        android:layout_marginBottom="6dp"
+        app:layout_constraintTop_toBottomOf="@+id/pairing_pic"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"/>
+
+    <RelativeLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:layout_constraintTop_toBottomOf="@+id/connect_progressbar"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent">
+
+      <ImageView
+          android:id="@+id/info_icon"
+          android:layout_width="24dp"
+          android:layout_height="24dp"
+          app:srcCompat="@drawable/fast_pair_ic_info"
+          android:layout_centerInParent="true"
+          android:contentDescription="@null"
+          android:layout_marginEnd="10dp"
+          android:layout_toStartOf="@id/connect_btn"
+          android:visibility="invisible" />
+
+      <com.google.android.material.button.MaterialButton
+          android:id="@+id/connect_btn"
+          android:layout_width="@dimen/fast_pair_half_sheet_image_size"
+          android:layout_height="wrap_content"
+          android:text="@string/paring_action_connect"
+          android:layout_centerInParent="true"
+          style="@style/HalfSheetButton" />
+
+    </RelativeLayout>
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/settings_btn"
+        android:text="@string/paring_action_settings"
+        android:layout_height="wrap_content"
+        android:layout_width="@dimen/fast_pair_half_sheet_image_size"
+        app:layout_constraintTop_toBottomOf="@+id/connect_progressbar"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        android:visibility="invisible"
+        style="@style/HalfSheetButton" />
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/cancel_btn"
+        android:text="@string/paring_action_done"
+        android:visibility="invisible"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        android:gravity="start|center_vertical"
+        android:layout_marginTop="6dp"
+        style="@style/HalfSheetButtonBorderless"/>
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/setup_btn"
+        android:text="@string/paring_action_launch"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        android:layout_marginTop="6dp"
+        android:layout_marginBottom="16dp"
+        android:background="@color/fast_pair_half_sheet_button_color"
+        android:visibility="invisible"
+        android:layout_height="@dimen/fast_pair_half_sheet_bottom_button_height"
+        android:layout_width="wrap_content"
+        style="@style/HalfSheetButton" />
+
+  </androidx.constraintlayout.widget.ConstraintLayout>
+
+</LinearLayout>
diff --git a/nearby/halfsheet/res/layout/fast_pair_half_sheet.xml b/nearby/halfsheet/res/layout/fast_pair_half_sheet.xml
new file mode 100644
index 0000000..705aa1b
--- /dev/null
+++ b/nearby/halfsheet/res/layout/fast_pair_half_sheet.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="RtlCompat, UselessParent, MergeRootFrame"
+    android:id="@+id/background"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+  <LinearLayout
+      android:id="@+id/card"
+      android:orientation="vertical"
+      android:transitionName="card"
+      android:layout_height="wrap_content"
+      android:layout_width="match_parent"
+      android:layout_gravity= "center|bottom"
+      android:paddingLeft="12dp"
+      android:paddingRight="12dp"
+      android:background="@drawable/half_sheet_bg"
+      android:accessibilityLiveRegion="polite"
+      android:gravity="bottom">
+
+    <RelativeLayout
+        android:id="@+id/toolbar_wrapper"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:paddingLeft="20dp"
+        android:paddingRight="20dp">
+
+      <ImageView
+          android:layout_marginTop="9dp"
+          android:layout_marginBottom="9dp"
+          android:id="@+id/toolbar_image"
+          android:layout_width="42dp"
+          android:layout_height="42dp"
+          android:contentDescription="@null"
+          android:layout_toStartOf="@id/toolbar_title"
+          android:layout_centerHorizontal="true"
+          android:visibility="invisible"/>
+
+      <TextView
+          android:layout_marginTop="18dp"
+          android:layout_marginBottom="18dp"
+          android:layout_centerHorizontal="true"
+          android:id="@+id/toolbar_title"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          android:fontFamily="google-sans-medium"
+          android:textSize="24sp"
+          android:textColor="@color/fast_pair_half_sheet_text_color"
+          style="@style/TextAppearance.AppCompat.Widget.ActionBar.Title" />
+    </RelativeLayout>
+
+    <FrameLayout
+        android:id="@+id/fragment_container"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+  </LinearLayout>
+
+</FrameLayout>
+
diff --git a/nearby/halfsheet/res/layout/fast_pair_heads_up_notification.xml b/nearby/halfsheet/res/layout/fast_pair_heads_up_notification.xml
new file mode 100644
index 0000000..11b8343
--- /dev/null
+++ b/nearby/halfsheet/res/layout/fast_pair_heads_up_notification.xml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal"
+    android:baselineAligned="false"
+    android:background="@color/fast_pair_notification_background"
+    tools:ignore="ContentDescription,UnusedAttribute,RtlCompat,Overdraw">
+
+  <LinearLayout
+      android:orientation="vertical"
+      android:layout_width="0dp"
+      android:layout_height="wrap_content"
+      android:layout_weight="1"
+      android:layout_marginTop="@dimen/fast_pair_notification_padding"
+      android:layout_marginStart="@dimen/fast_pair_notification_padding"
+      android:layout_marginEnd="@dimen/fast_pair_notification_padding">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+      <TextView
+          android:id="@android:id/title"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          android:fontFamily="sans-serif-medium"
+          android:textSize="@dimen/fast_pair_notification_text_size"
+          android:textColor="@color/fast_pair_primary_text"
+          android:layout_marginBottom="2dp"
+          android:lines="1"/>
+
+      <TextView
+          android:id="@android:id/text2"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          android:textSize="@dimen/fast_pair_notification_text_size_small"
+          android:textColor="@color/fast_pair_primary_text"
+          android:layout_marginBottom="2dp"
+          android:layout_marginStart="4dp"
+          android:lines="1"/>
+    </LinearLayout>
+
+    <TextView
+        android:id="@android:id/text1"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="@dimen/fast_pair_notification_text_size"
+        android:textColor="@color/fast_pair_primary_text"
+        android:maxLines="2"
+        android:ellipsize="end"
+        android:breakStrategy="simple" />
+
+    <FrameLayout
+        android:id="@android:id/secondaryProgress"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:layout_marginEnd="32dp"
+        android:orientation="horizontal"
+        android:visibility="gone">
+
+      <ProgressBar
+          android:id="@android:id/progress"
+          style="?android:attr/progressBarStyleHorizontal"
+          android:layout_width="match_parent"
+          android:layout_height="wrap_content"
+          android:layout_gravity="center_vertical"
+          android:indeterminateTint="@color/discovery_activity_accent"/>
+
+    </FrameLayout>
+
+  </LinearLayout>
+
+  <FrameLayout
+      android:id="@android:id/icon1"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"/>
+
+</LinearLayout>
diff --git a/nearby/halfsheet/res/layout/fast_pair_heads_up_notification_large_image.xml b/nearby/halfsheet/res/layout/fast_pair_heads_up_notification_large_image.xml
new file mode 100644
index 0000000..dd28947
--- /dev/null
+++ b/nearby/halfsheet/res/layout/fast_pair_heads_up_notification_large_image.xml
@@ -0,0 +1,7 @@
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@android:id/icon"
+    android:layout_width="@dimen/fast_pair_notification_large_image_size"
+    android:layout_height="@dimen/fast_pair_notification_large_image_size"
+    android:scaleType="fitStart"
+    tools:ignore="ContentDescription"/>
diff --git a/nearby/halfsheet/res/layout/fast_pair_heads_up_notification_small_image.xml b/nearby/halfsheet/res/layout/fast_pair_heads_up_notification_small_image.xml
new file mode 100644
index 0000000..ee1d89f
--- /dev/null
+++ b/nearby/halfsheet/res/layout/fast_pair_heads_up_notification_small_image.xml
@@ -0,0 +1,11 @@
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@android:id/icon"
+    android:layout_width="@dimen/fast_pair_notification_small_image_size"
+    android:layout_height="@dimen/fast_pair_notification_small_image_size"
+    android:layout_marginTop="@dimen/fast_pair_notification_padding"
+    android:layout_marginBottom="@dimen/fast_pair_notification_padding"
+    android:layout_marginStart="@dimen/fast_pair_notification_padding"
+    android:layout_marginEnd="@dimen/fast_pair_notification_padding"
+    android:scaleType="fitStart"
+    tools:ignore="ContentDescription,RtlCompat"/>
diff --git a/nearby/halfsheet/res/values/colors.xml b/nearby/halfsheet/res/values/colors.xml
new file mode 100644
index 0000000..b066665
--- /dev/null
+++ b/nearby/halfsheet/res/values/colors.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+  <!-- Use original background color -->
+  <color name="fast_pair_notification_background">#00000000</color>
+  <!-- Ignores NewApi as below system colors are available since API 31, and HalfSheet is always
+       running on T+ even though it has min_sdk 30 to match its containing APEX -->
+  <color name="fast_pair_half_sheet_button_color" tools:ignore="NewApi">@android:color/system_accent1_100</color>
+  <color name="fast_pair_half_sheet_button_text" tools:ignore="NewApi">@android:color/system_neutral1_900</color>
+  <color name="fast_pair_half_sheet_button_accent_text" tools:ignore="NewApi">@android:color/system_neutral1_900</color>
+  <color name="fast_pair_progress_color" tools:ignore="NewApi">@android:color/system_accent1_600</color>
+  <color name="fast_pair_half_sheet_subtitle_color" tools:ignore="NewApi">@android:color/system_neutral2_700</color>
+  <color name="fast_pair_half_sheet_text_color" tools:ignore="NewApi">@android:color/system_neutral1_900</color>
+
+  <!-- Nearby Discoverer -->
+  <color name="discovery_activity_accent">#4285F4</color>
+
+  <!-- Fast Pair -->
+  <color name="fast_pair_primary_text">#DE000000</color>
+  <color name="fast_pair_notification_image_outline">#24000000</color>
+  <color name="fast_pair_battery_level_low">#D93025</color>
+  <color name="fast_pair_battery_level_normal">#80868B</color>
+  <color name="fast_pair_half_sheet_background">#FFFFFF</color>
+  <color name="fast_pair_half_sheet_color_accent">#1A73E8</color>
+  <color name="fast_pair_fail_progress_color">#F44336</color>
+  <color name="fast_pair_progress_back_ground">#24000000</color>
+</resources>
diff --git a/nearby/halfsheet/res/values/dimens.xml b/nearby/halfsheet/res/values/dimens.xml
new file mode 100644
index 0000000..f843042
--- /dev/null
+++ b/nearby/halfsheet/res/values/dimens.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <!-- Fast Pair notification values -->
+  <dimen name="fast_pair_halfsheet_mid_image_size">160dp</dimen>
+  <dimen name="fast_pair_notification_text_size">14sp</dimen>
+  <dimen name="fast_pair_notification_text_size_small">11sp</dimen>
+  <dimen name="fast_pair_battery_notification_empty_view_height">4dp</dimen>
+  <dimen name="fast_pair_battery_notification_margin_top">8dp</dimen>
+  <dimen name="fast_pair_battery_notification_margin_bottom">8dp</dimen>
+  <dimen name="fast_pair_battery_notification_content_height">40dp</dimen>
+  <dimen name="fast_pair_battery_notification_content_height_v2">64dp</dimen>
+  <dimen name="fast_pair_battery_notification_image_size">32dp</dimen>
+  <dimen name="fast_pair_battery_notification_image_padding">3dp</dimen>
+  <dimen name="fast_pair_half_sheet_min_height">350dp</dimen>
+  <dimen name="fast_pair_half_sheet_image_size">215dp</dimen>
+  <dimen name="fast_pair_half_sheet_land_image_size">136dp</dimen>
+  <dimen name="fast_pair_connect_button_height">36dp</dimen>
+  <dimen name="accessibility_required_min_touch_target_size">48dp</dimen>
+  <dimen name="fast_pair_half_sheet_battery_case_image_size">152dp</dimen>
+  <dimen name="fast_pair_half_sheet_battery_bud_image_size">100dp</dimen>
+  <integer name="half_sheet_battery_case_width_dp">156</integer>
+  <integer name="half_sheet_battery_case_height_dp">182</integer>
+
+  <!-- Maximum height for SliceView, override on slices/view/src/main/res/values/dimens.xml -->
+  <dimen name="abc_slice_large_height">360dp</dimen>
+
+  <dimen name="action_dialog_content_margin_left">16dp</dimen>
+  <dimen name="action_dialog_content_margin_top">70dp</dimen>
+  <dimen name="action_button_focused_elevation">4dp</dimen>
+  <!-- Subsequent Notification -->
+  <dimen name="fast_pair_notification_padding">4dp</dimen>
+  <dimen name="fast_pair_notification_large_image_size">32dp</dimen>
+  <dimen name="fast_pair_notification_small_image_size">32dp</dimen>
+  <!-- Battery Notification -->
+  <dimen name="fast_pair_battery_notification_main_view_padding">0dp</dimen>
+  <dimen name="fast_pair_battery_notification_title_image_margin_start">0dp</dimen>
+  <dimen name="fast_pair_battery_notification_title_text_margin_start">0dp</dimen>
+  <dimen name="fast_pair_battery_notification_title_text_margin_start_v2">0dp</dimen>
+  <dimen name="fast_pair_battery_notification_image_margin_start">0dp</dimen>
+
+  <dimen name="fast_pair_half_sheet_bottom_button_height">48dp</dimen>
+</resources>
diff --git a/nearby/halfsheet/res/values/ints.xml b/nearby/halfsheet/res/values/ints.xml
new file mode 100644
index 0000000..07bf9d2
--- /dev/null
+++ b/nearby/halfsheet/res/values/ints.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <integer name="half_sheet_slide_in_duration">250</integer>
+  <integer name="half_sheet_fade_out_duration">250</integer>
+</resources>
diff --git a/nearby/halfsheet/res/values/overlayable.xml b/nearby/halfsheet/res/values/overlayable.xml
new file mode 100644
index 0000000..fffa2e3
--- /dev/null
+++ b/nearby/halfsheet/res/values/overlayable.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+    <overlayable name="NearbyHalfSheetResourcesConfig">
+        <policy type="product|system|vendor">
+            <item type="color" name="fast_pair_half_sheet_background"/>
+            <item type="color" name="fast_pair_half_sheet_button_color"/>
+        </policy>
+    </overlayable>
+</resources>
\ No newline at end of file
diff --git a/nearby/halfsheet/res/values/strings.xml b/nearby/halfsheet/res/values/strings.xml
new file mode 100644
index 0000000..01a82e4
--- /dev/null
+++ b/nearby/halfsheet/res/values/strings.xml
@@ -0,0 +1,72 @@
+<!--
+  ~ 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.
+  -->
+
+<resources>
+
+    <!--
+      ============================================================
+      PAIRING FRAGMENT
+      ============================================================
+    -->
+
+    <!--
+      A button shown to remind user setup is in progress. [CHAR LIMIT=30]
+    -->
+    <string name="fast_pair_setup_in_progress">Starting Setup&#x2026;</string>
+    <!--
+      Title text shown to remind user to setup a device through companion app. [CHAR LIMIT=40]
+    -->
+    <string name="fast_pair_title_setup">Set up device</string>
+    <!--
+      Title after we successfully pair with the audio device
+      [CHAR LIMIT=30]
+    -->
+    <string name="fast_pair_device_ready">Device connected</string>
+    <!-- Title text shown when peripheral device fail to connect to phone. [CHAR_LIMIT=30] -->
+    <string name="fast_pair_title_fail">Couldn\'t connect</string>
+
+    <!--
+      ============================================================
+      MISCELLANEOUS
+      ============================================================
+    -->
+
+    <!--
+      A button shown after paring process to dismiss the current activity.
+      [CHAR LIMIT=30]
+    -->
+    <string name="paring_action_done">Done</string>
+    <!--
+      A button shown for retroactive paring.
+      [CHAR LIMIT=30]
+     -->
+    <string name="paring_action_save">Save</string>
+    <!--
+      A button to start connecting process.
+      [CHAR LIMIT=30]
+     -->
+    <string name="paring_action_connect">Connect</string>
+    <!--
+      A button to launch a companion app.
+      [CHAR LIMIT=30]
+    -->
+    <string name="paring_action_launch">Set up</string>
+    <!--
+      A button to launch a bluetooth Settings page.
+      [CHAR LIMIT=20]
+    -->
+    <string name="paring_action_settings">Settings</string>
+</resources>
\ No newline at end of file
diff --git a/nearby/halfsheet/res/values/styles.xml b/nearby/halfsheet/res/values/styles.xml
new file mode 100644
index 0000000..917bb63
--- /dev/null
+++ b/nearby/halfsheet/res/values/styles.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+  <style name="HalfSheetStyle" parent="Theme.Material3.DayNight.NoActionBar">
+    <item name="android:windowFrame">@null</item>
+    <item name="android:windowBackground">@android:color/transparent</item>
+    <item name="android:windowEnterAnimation">@anim/fast_pair_half_sheet_slide_in</item>
+    <item name="android:windowExitAnimation">@anim/fast_pair_half_sheet_slide_out</item>
+    <item name="android:windowIsTranslucent">true</item>
+    <item name="android:windowContentOverlay">@null</item>
+    <item name="android:windowNoTitle">true</item>
+    <item name="android:backgroundDimEnabled">true</item>
+    <item name="android:statusBarColor">@android:color/transparent</item>
+    <item name="android:fitsSystemWindows">true</item>
+    <item name="android:windowTranslucentNavigation">true</item>
+  </style>
+
+  <style name="HalfSheetButton" parent="@style/Widget.Material3.Button.TonalButton">
+    <item name="android:textColor">@color/fast_pair_half_sheet_button_accent_text</item>
+    <item name="android:backgroundTint">@color/fast_pair_half_sheet_button_color</item>
+    <item name="android:textSize">@dimen/fast_pair_notification_text_size</item>
+    <item name="android:fontFamily">google-sans-medium</item>
+    <item name="android:textAlignment">center</item>
+    <item name="android:textAllCaps">false</item>
+  </style>
+
+  <style name="HalfSheetButtonBorderless" parent="@style/Widget.Material3.Button.OutlinedButton">
+    <item name="android:textColor">@color/fast_pair_half_sheet_button_text</item>
+    <item name="android:strokeColor">@color/fast_pair_half_sheet_button_color</item>
+    <item name="android:textAllCaps">false</item>
+    <item name="android:textSize">@dimen/fast_pair_notification_text_size</item>
+    <item name="android:fontFamily">google-sans-medium</item>
+    <item name="android:layout_width">wrap_content</item>
+    <item name="android:layout_height">wrap_content</item>
+    <item name="android:textAlignment">center</item>
+    <item name="android:minHeight">@dimen/accessibility_required_min_touch_target_size</item>
+  </style>
+
+</resources>
diff --git a/nearby/halfsheet/src/com/android/nearby/halfsheet/HalfSheetActivity.java b/nearby/halfsheet/src/com/android/nearby/halfsheet/HalfSheetActivity.java
new file mode 100644
index 0000000..9507b9b
--- /dev/null
+++ b/nearby/halfsheet/src/com/android/nearby/halfsheet/HalfSheetActivity.java
@@ -0,0 +1,239 @@
+/*
+ * 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.nearby.halfsheet;
+
+import static com.android.nearby.halfsheet.fragment.DevicePairingFragment.APP_LAUNCH_FRAGMENT_TYPE;
+import static com.android.server.nearby.common.bluetooth.fastpair.FastPairConstants.EXTRA_MODEL_ID;
+import static com.android.server.nearby.common.fastpair.service.UserActionHandlerBase.EXTRA_MAC_ADDRESS;
+import static com.android.server.nearby.fastpair.Constant.ACTION_FAST_PAIR_HALF_SHEET_CANCEL;
+import static com.android.server.nearby.fastpair.Constant.DEVICE_PAIRING_FRAGMENT_TYPE;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_HALF_SHEET_INFO;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_HALF_SHEET_TYPE;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+
+import com.android.nearby.halfsheet.fragment.DevicePairingFragment;
+import com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment;
+import com.android.nearby.halfsheet.utils.BroadcastUtils;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import java.util.Locale;
+
+import service.proto.Cache;
+
+/**
+ * A class show Fast Pair related information in Half sheet format.
+ */
+public class HalfSheetActivity extends FragmentActivity {
+
+    public static final String TAG = "HalfSheetActivity";
+
+    public static final String EXTRA_HALF_SHEET_CONTENT =
+            "com.android.nearby.halfsheet.HALF_SHEET_CONTENT";
+    public static final String EXTRA_TITLE =
+            "com.android.nearby.halfsheet.HALF_SHEET_TITLE";
+    public static final String EXTRA_DESCRIPTION =
+            "com.android.nearby.halfsheet.HALF_SHEET_DESCRIPTION";
+    public static final String EXTRA_HALF_SHEET_ID =
+            "com.android.nearby.halfsheet.HALF_SHEET_ID";
+    public static final String EXTRA_HALF_SHEET_IS_RETROACTIVE =
+            "com.android.nearby.halfsheet.HALF_SHEET_IS_RETROACTIVE";
+    public static final String EXTRA_HALF_SHEET_IS_SUBSEQUENT_PAIR =
+            "com.android.nearby.halfsheet.HALF_SHEET_IS_SUBSEQUENT_PAIR";
+    public static final String EXTRA_HALF_SHEET_PAIRING_RESURFACE =
+            "com.android.nearby.halfsheet.EXTRA_HALF_SHEET_PAIRING_RESURFACE";
+    public static final String ACTION_HALF_SHEET_FOREGROUND_STATE =
+            "com.android.nearby.halfsheet.ACTION_HALF_SHEET_FOREGROUND_STATE";
+    // Intent extra contains the user gmail name eg. testaccount@gmail.com.
+    public static final String EXTRA_HALF_SHEET_ACCOUNT_NAME =
+            "com.android.nearby.halfsheet.HALF_SHEET_ACCOUNT_NAME";
+    public static final String EXTRA_HALF_SHEET_FOREGROUND =
+            "com.android.nearby.halfsheet.EXTRA_HALF_SHEET_FOREGROUND";
+    public static final String ARG_FRAGMENT_STATE = "ARG_FRAGMENT_STATE";
+    @Nullable
+    private HalfSheetModuleFragment mHalfSheetModuleFragment;
+    @Nullable
+    private Cache.ScanFastPairStoreItem mScanFastPairStoreItem;
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        byte[] infoArray = getIntent().getByteArrayExtra(EXTRA_HALF_SHEET_INFO);
+        String fragmentType = getIntent().getStringExtra(EXTRA_HALF_SHEET_TYPE);
+        if (infoArray == null || fragmentType == null) {
+            Log.d(
+                    "HalfSheetActivity",
+                    "exit flag off or do not have enough half sheet information.");
+            finish();
+            return;
+        }
+
+        switch (fragmentType) {
+            case DEVICE_PAIRING_FRAGMENT_TYPE:
+                mHalfSheetModuleFragment = DevicePairingFragment.newInstance(getIntent(),
+                        savedInstanceState);
+                if (mHalfSheetModuleFragment == null) {
+                    Log.d(TAG, "device pairing fragment has error.");
+                    finish();
+                    return;
+                }
+                break;
+            case APP_LAUNCH_FRAGMENT_TYPE:
+                // currentFragment = AppLaunchFragment.newInstance(getIntent());
+                if (mHalfSheetModuleFragment == null) {
+                    Log.v(TAG, "app launch fragment has error.");
+                    finish();
+                    return;
+                }
+                break;
+            default:
+                Log.w(TAG, "there is no valid type for half sheet");
+                finish();
+                return;
+        }
+        if (mHalfSheetModuleFragment != null) {
+            getSupportFragmentManager()
+                    .beginTransaction()
+                    .replace(R.id.fragment_container, mHalfSheetModuleFragment)
+                    .commit();
+        }
+        setContentView(R.layout.fast_pair_half_sheet);
+
+        // If the user taps on the background, then close the activity.
+        // Unless they tap on the card itself, then ignore the tap.
+        findViewById(R.id.background).setOnClickListener(v -> onCancelClicked());
+        findViewById(R.id.card)
+                .setOnClickListener(
+                        v -> Log.v(TAG, "card view is clicked noop"));
+        try {
+            mScanFastPairStoreItem =
+                    Cache.ScanFastPairStoreItem.parseFrom(infoArray);
+        } catch (InvalidProtocolBufferException e) {
+            Log.w(
+                    TAG, "error happens when pass info to half sheet");
+        }
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+    }
+
+    @Override
+    protected void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
+        super.onSaveInstanceState(savedInstanceState);
+        if (mHalfSheetModuleFragment != null) {
+            mHalfSheetModuleFragment.onSaveInstanceState(savedInstanceState);
+        }
+    }
+
+    @Override
+    public void onBackPressed() {
+        super.onBackPressed();
+        sendHalfSheetCancelBroadcast();
+    }
+
+    @Override
+    protected void onUserLeaveHint() {
+        super.onUserLeaveHint();
+        sendHalfSheetCancelBroadcast();
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        String fragmentType = getIntent().getStringExtra(EXTRA_HALF_SHEET_TYPE);
+        if (fragmentType == null) {
+            return;
+        }
+        if (fragmentType.equals(DEVICE_PAIRING_FRAGMENT_TYPE)
+                && intent.getExtras() != null
+                && intent.getByteArrayExtra(EXTRA_HALF_SHEET_INFO) != null) {
+            try {
+                Cache.ScanFastPairStoreItem testScanFastPairStoreItem =
+                        Cache.ScanFastPairStoreItem.parseFrom(
+                                intent.getByteArrayExtra(EXTRA_HALF_SHEET_INFO));
+                if (mScanFastPairStoreItem != null
+                        && !testScanFastPairStoreItem.getAddress().equals(
+                        mScanFastPairStoreItem.getAddress())
+                        && testScanFastPairStoreItem.getModelId().equals(
+                        mScanFastPairStoreItem.getModelId())) {
+                    Log.d(TAG, "possible factory reset happens");
+                    halfSheetStateChange();
+                }
+            } catch (InvalidProtocolBufferException | NullPointerException e) {
+                Log.w(TAG, "error happens when pass info to half sheet");
+            }
+        }
+    }
+
+    /** This function should be called when user click empty area and cancel button. */
+    public void onCancelClicked() {
+        Log.d(TAG, "Cancels the half sheet and paring.");
+        sendHalfSheetCancelBroadcast();
+        finish();
+    }
+
+    /** Changes the half sheet foreground state to false. */
+    public void halfSheetStateChange() {
+        BroadcastUtils.sendBroadcast(
+                this,
+                new Intent(ACTION_HALF_SHEET_FOREGROUND_STATE)
+                        .putExtra(EXTRA_HALF_SHEET_FOREGROUND, false));
+        finish();
+    }
+
+    private void sendHalfSheetCancelBroadcast() {
+        BroadcastUtils.sendBroadcast(
+                this,
+                new Intent(ACTION_HALF_SHEET_FOREGROUND_STATE)
+                        .putExtra(EXTRA_HALF_SHEET_FOREGROUND, false));
+        if (mScanFastPairStoreItem != null) {
+            BroadcastUtils.sendBroadcast(
+                    this,
+                    new Intent(ACTION_FAST_PAIR_HALF_SHEET_CANCEL)
+                            .putExtra(EXTRA_MODEL_ID,
+                                    mScanFastPairStoreItem.getModelId().toLowerCase(Locale.ROOT))
+                            .putExtra(EXTRA_HALF_SHEET_TYPE,
+                                    getIntent().getStringExtra(EXTRA_HALF_SHEET_TYPE))
+                            .putExtra(
+                                    EXTRA_HALF_SHEET_IS_SUBSEQUENT_PAIR,
+                                    getIntent().getBooleanExtra(EXTRA_HALF_SHEET_IS_SUBSEQUENT_PAIR,
+                                            false))
+                            .putExtra(
+                                    EXTRA_HALF_SHEET_IS_RETROACTIVE,
+                                    getIntent().getBooleanExtra(EXTRA_HALF_SHEET_IS_RETROACTIVE,
+                                            false))
+                            .putExtra(EXTRA_MAC_ADDRESS, mScanFastPairStoreItem.getAddress()));
+        }
+    }
+
+    @Override
+    public void setTitle(CharSequence title) {
+        super.setTitle(title);
+        TextView toolbarTitle = findViewById(R.id.toolbar_title);
+        toolbarTitle.setText(title);
+    }
+}
diff --git a/nearby/halfsheet/src/com/android/nearby/halfsheet/fragment/DevicePairingFragment.java b/nearby/halfsheet/src/com/android/nearby/halfsheet/fragment/DevicePairingFragment.java
new file mode 100644
index 0000000..a62c8cc
--- /dev/null
+++ b/nearby/halfsheet/src/com/android/nearby/halfsheet/fragment/DevicePairingFragment.java
@@ -0,0 +1,486 @@
+/*
+ * 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.nearby.halfsheet.fragment;
+
+import static android.text.TextUtils.isEmpty;
+
+import static com.android.nearby.halfsheet.HalfSheetActivity.ARG_FRAGMENT_STATE;
+import static com.android.nearby.halfsheet.HalfSheetActivity.EXTRA_DESCRIPTION;
+import static com.android.nearby.halfsheet.HalfSheetActivity.EXTRA_HALF_SHEET_ACCOUNT_NAME;
+import static com.android.nearby.halfsheet.HalfSheetActivity.EXTRA_HALF_SHEET_CONTENT;
+import static com.android.nearby.halfsheet.HalfSheetActivity.EXTRA_HALF_SHEET_ID;
+import static com.android.nearby.halfsheet.HalfSheetActivity.EXTRA_TITLE;
+import static com.android.nearby.halfsheet.HalfSheetActivity.TAG;
+import static com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment.HalfSheetFragmentState.FAILED;
+import static com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment.HalfSheetFragmentState.FOUND_DEVICE;
+import static com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment.HalfSheetFragmentState.NOT_STARTED;
+import static com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment.HalfSheetFragmentState.PAIRED_LAUNCHABLE;
+import static com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment.HalfSheetFragmentState.PAIRED_UNLAUNCHABLE;
+import static com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment.HalfSheetFragmentState.PAIRING;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_BINDER;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_BUNDLE;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_HALF_SHEET_INFO;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.nearby.FastPairClient;
+import android.nearby.FastPairDevice;
+import android.nearby.FastPairStatusCallback;
+import android.nearby.NearbyDevice;
+import android.nearby.PairStatusMetadata;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+
+import com.android.nearby.halfsheet.HalfSheetActivity;
+import com.android.nearby.halfsheet.R;
+import com.android.nearby.halfsheet.utils.FastPairUtils;
+import com.android.nearby.halfsheet.utils.IconUtils;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import java.util.Objects;
+
+import service.proto.Cache.ScanFastPairStoreItem;
+
+/**
+ * Modularize half sheet for fast pair this fragment will show when half sheet does device pairing.
+ *
+ * <p>This fragment will handle initial pairing subsequent pairing and retroactive pairing.
+ */
+@SuppressWarnings("nullness")
+public class DevicePairingFragment extends HalfSheetModuleFragment implements
+        FastPairStatusCallback {
+    private TextView mTitleView;
+    private TextView mSubTitleView;
+    private ImageView mImage;
+
+    private Button mConnectButton;
+    private Button mSetupButton;
+    private Button mCancelButton;
+    // Opens Bluetooth Settings.
+    private Button mSettingsButton;
+    private ImageView mInfoIconButton;
+    private ProgressBar mConnectProgressBar;
+
+    private Bundle mBundle;
+
+    private ScanFastPairStoreItem mScanFastPairStoreItem;
+    private FastPairClient mFastPairClient;
+
+    private @PairStatusMetadata.Status int mPairStatus = PairStatusMetadata.Status.UNKNOWN;
+    // True when there is a companion app to open.
+    private boolean mIsLaunchable;
+    private boolean mIsConnecting;
+    // Indicates that the setup button is clicked before.
+    private boolean mSetupButtonClicked = false;
+
+    // Holds the new text while we transition between the two.
+    private static final int TAG_PENDING_TEXT = R.id.toolbar_title;
+    public static final String APP_LAUNCH_FRAGMENT_TYPE = "APP_LAUNCH";
+
+    private static final String ARG_SETUP_BUTTON_CLICKED = "SETUP_BUTTON_CLICKED";
+    private static final String ARG_PAIRING_RESULT = "PAIRING_RESULT";
+
+    /**
+     * Create certain fragment according to the intent.
+     */
+    @Nullable
+    public static HalfSheetModuleFragment newInstance(
+            Intent intent, @Nullable Bundle saveInstanceStates) {
+        Bundle args = new Bundle();
+        byte[] infoArray = intent.getByteArrayExtra(EXTRA_HALF_SHEET_INFO);
+
+        Bundle bundle = intent.getBundleExtra(EXTRA_BUNDLE);
+        String title = intent.getStringExtra(EXTRA_TITLE);
+        String description = intent.getStringExtra(EXTRA_DESCRIPTION);
+        String accountName = intent.getStringExtra(EXTRA_HALF_SHEET_ACCOUNT_NAME);
+        String result = intent.getStringExtra(EXTRA_HALF_SHEET_CONTENT);
+        int halfSheetId = intent.getIntExtra(EXTRA_HALF_SHEET_ID, 0);
+
+        args.putByteArray(EXTRA_HALF_SHEET_INFO, infoArray);
+        args.putString(EXTRA_HALF_SHEET_ACCOUNT_NAME, accountName);
+        args.putString(EXTRA_TITLE, title);
+        args.putString(EXTRA_DESCRIPTION, description);
+        args.putInt(EXTRA_HALF_SHEET_ID, halfSheetId);
+        args.putString(EXTRA_HALF_SHEET_CONTENT, result == null ? "" : result);
+        args.putBundle(EXTRA_BUNDLE, bundle);
+        if (saveInstanceStates != null) {
+            if (saveInstanceStates.containsKey(ARG_FRAGMENT_STATE)) {
+                args.putSerializable(
+                        ARG_FRAGMENT_STATE, saveInstanceStates.getSerializable(ARG_FRAGMENT_STATE));
+            }
+            if (saveInstanceStates.containsKey(BluetoothDevice.EXTRA_DEVICE)) {
+                args.putParcelable(
+                        BluetoothDevice.EXTRA_DEVICE,
+                        saveInstanceStates.getParcelable(BluetoothDevice.EXTRA_DEVICE));
+            }
+            if (saveInstanceStates.containsKey(BluetoothDevice.EXTRA_PAIRING_KEY)) {
+                args.putInt(
+                        BluetoothDevice.EXTRA_PAIRING_KEY,
+                        saveInstanceStates.getInt(BluetoothDevice.EXTRA_PAIRING_KEY));
+            }
+            if (saveInstanceStates.containsKey(ARG_SETUP_BUTTON_CLICKED)) {
+                args.putBoolean(
+                        ARG_SETUP_BUTTON_CLICKED,
+                        saveInstanceStates.getBoolean(ARG_SETUP_BUTTON_CLICKED));
+            }
+            if (saveInstanceStates.containsKey(ARG_PAIRING_RESULT)) {
+                args.putBoolean(ARG_PAIRING_RESULT,
+                        saveInstanceStates.getBoolean(ARG_PAIRING_RESULT));
+            }
+        }
+        DevicePairingFragment fragment = new DevicePairingFragment();
+        fragment.setArguments(args);
+        return fragment;
+    }
+
+    @Nullable
+    @Override
+    public View onCreateView(
+            LayoutInflater inflater, @Nullable ViewGroup container,
+            @Nullable Bundle savedInstanceState) {
+        /* attachToRoot= */
+        View rootView = inflater.inflate(
+                R.layout.fast_pair_device_pairing_fragment, container, /* attachToRoot= */
+                false);
+        if (getContext() == null) {
+            Log.d(TAG, "can't find the attached activity");
+            return rootView;
+        }
+
+        Bundle args = getArguments();
+        byte[] storeFastPairItemBytesArray = args.getByteArray(EXTRA_HALF_SHEET_INFO);
+        mBundle = args.getBundle(EXTRA_BUNDLE);
+        if (mBundle != null) {
+            mFastPairClient = new FastPairClient(getContext(), mBundle.getBinder(EXTRA_BINDER));
+            mFastPairClient.registerHalfSheet(this);
+        }
+        if (args.containsKey(ARG_FRAGMENT_STATE)) {
+            mFragmentState = (HalfSheetFragmentState) args.getSerializable(ARG_FRAGMENT_STATE);
+        }
+        if (args.containsKey(ARG_SETUP_BUTTON_CLICKED)) {
+            mSetupButtonClicked = args.getBoolean(ARG_SETUP_BUTTON_CLICKED);
+        }
+        if (args.containsKey(ARG_PAIRING_RESULT)) {
+            mPairStatus = args.getInt(ARG_PAIRING_RESULT);
+        }
+
+        // Initiate views.
+        mTitleView = Objects.requireNonNull(getActivity()).findViewById(R.id.toolbar_title);
+        mSubTitleView = rootView.findViewById(R.id.header_subtitle);
+        mImage = rootView.findViewById(R.id.pairing_pic);
+        mConnectProgressBar = rootView.findViewById(R.id.connect_progressbar);
+        mConnectButton = rootView.findViewById(R.id.connect_btn);
+        mCancelButton = rootView.findViewById(R.id.cancel_btn);
+        mSettingsButton = rootView.findViewById(R.id.settings_btn);
+        mSetupButton = rootView.findViewById(R.id.setup_btn);
+        mInfoIconButton = rootView.findViewById(R.id.info_icon);
+        mInfoIconButton.setImageResource(R.drawable.fast_pair_ic_info);
+
+        try {
+            setScanFastPairStoreItem(ScanFastPairStoreItem.parseFrom(storeFastPairItemBytesArray));
+        } catch (InvalidProtocolBufferException e) {
+            Log.w(TAG,
+                    "DevicePairingFragment: error happens when pass info to half sheet");
+            return rootView;
+        }
+
+        // Config for landscape mode
+        DisplayMetrics displayMetrics = getContext().getResources().getDisplayMetrics();
+        if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
+            rootView.getLayoutParams().height = displayMetrics.heightPixels * 4 / 5;
+            rootView.getLayoutParams().width = displayMetrics.heightPixels * 4 / 5;
+            mImage.getLayoutParams().height = displayMetrics.heightPixels / 2;
+            mImage.getLayoutParams().width = displayMetrics.heightPixels / 2;
+            mConnectProgressBar.getLayoutParams().width = displayMetrics.heightPixels / 2;
+            mConnectButton.getLayoutParams().width = displayMetrics.heightPixels / 2;
+            //TODO(b/213373051): Add cancel button
+        }
+
+        Bitmap icon = IconUtils.getIcon(mScanFastPairStoreItem.getIconPng().toByteArray(),
+                mScanFastPairStoreItem.getIconPng().size());
+        if (icon != null) {
+            mImage.setImageBitmap(icon);
+        }
+        mConnectButton.setOnClickListener(v -> onConnectClick());
+        mCancelButton.setOnClickListener(v ->
+                ((HalfSheetActivity) getActivity()).onCancelClicked());
+        mSettingsButton.setOnClickListener(v -> onSettingsClicked());
+        mSetupButton.setOnClickListener(v -> onSetupClick());
+
+        return rootView;
+    }
+
+    @Override
+    public void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        // Get access to the activity's menu
+        setHasOptionsMenu(true);
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        Log.v(TAG, "onStart: invalidate states");
+        invalidateState();
+    }
+
+    @Override
+    public void onSaveInstanceState(Bundle savedInstanceState) {
+        super.onSaveInstanceState(savedInstanceState);
+
+        savedInstanceState.putSerializable(ARG_FRAGMENT_STATE, mFragmentState);
+        savedInstanceState.putBoolean(ARG_SETUP_BUTTON_CLICKED, mSetupButtonClicked);
+        savedInstanceState.putInt(ARG_PAIRING_RESULT, mPairStatus);
+    }
+
+    private void onSettingsClicked() {
+        startActivity(new Intent(Settings.ACTION_BLUETOOTH_SETTINGS));
+    }
+
+    private void onSetupClick() {
+        String companionApp =
+                FastPairUtils.getCompanionAppFromActionUrl(mScanFastPairStoreItem.getActionUrl());
+        Intent intent =
+                FastPairUtils.createCompanionAppIntent(
+                        Objects.requireNonNull(getContext()),
+                        companionApp,
+                        mScanFastPairStoreItem.getAddress());
+        mSetupButtonClicked = true;
+        if (mFragmentState == PAIRED_LAUNCHABLE) {
+            if (intent != null) {
+                startActivity(intent);
+            }
+        } else {
+            Log.d(TAG, "onSetupClick: State is " + mFragmentState);
+        }
+    }
+
+    private void onConnectClick() {
+        if (mScanFastPairStoreItem == null) {
+            Log.w(TAG, "No pairing related information in half sheet");
+            return;
+        }
+        if (getFragmentState() == PAIRING) {
+            return;
+        }
+        mIsConnecting = true;
+        invalidateState();
+        mFastPairClient.connect(
+                new FastPairDevice.Builder()
+                        .addMedium(NearbyDevice.Medium.BLE)
+                        .setBluetoothAddress(mScanFastPairStoreItem.getAddress())
+                        .setData(FastPairUtils.convertFrom(mScanFastPairStoreItem)
+                                .toByteArray())
+                        .build());
+    }
+
+    // Receives callback from service.
+    @Override
+    public void onPairUpdate(FastPairDevice fastPairDevice, PairStatusMetadata pairStatusMetadata) {
+        @PairStatusMetadata.Status int status = pairStatusMetadata.getStatus();
+        if (status == PairStatusMetadata.Status.DISMISS && getActivity() != null) {
+            getActivity().finish();
+        }
+        mIsConnecting = false;
+        mPairStatus = status;
+        invalidateState();
+    }
+
+    @Override
+    public void invalidateState() {
+        HalfSheetFragmentState newState = NOT_STARTED;
+        if (mIsConnecting) {
+            newState = PAIRING;
+        } else {
+            switch (mPairStatus) {
+                case PairStatusMetadata.Status.SUCCESS:
+                    newState = mIsLaunchable ? PAIRED_LAUNCHABLE : PAIRED_UNLAUNCHABLE;
+                    break;
+                case PairStatusMetadata.Status.FAIL:
+                    newState = FAILED;
+                    break;
+                default:
+                    if (mScanFastPairStoreItem != null) {
+                        newState = FOUND_DEVICE;
+                    }
+            }
+        }
+        if (newState == mFragmentState) {
+            return;
+        }
+        setState(newState);
+    }
+
+    @Override
+    public void setState(HalfSheetFragmentState state) {
+        super.setState(state);
+        invalidateTitles();
+        invalidateButtons();
+    }
+
+    private void setScanFastPairStoreItem(ScanFastPairStoreItem item) {
+        mScanFastPairStoreItem = item;
+        invalidateLaunchable();
+    }
+
+    private void invalidateLaunchable() {
+        String companionApp =
+                FastPairUtils.getCompanionAppFromActionUrl(mScanFastPairStoreItem.getActionUrl());
+        if (isEmpty(companionApp)) {
+            mIsLaunchable = false;
+            return;
+        }
+        mIsLaunchable =
+                FastPairUtils.isLaunchable(Objects.requireNonNull(getContext()), companionApp);
+    }
+
+    private void invalidateButtons() {
+        mConnectProgressBar.setVisibility(View.INVISIBLE);
+        mConnectButton.setVisibility(View.INVISIBLE);
+        mCancelButton.setVisibility(View.INVISIBLE);
+        mSetupButton.setVisibility(View.INVISIBLE);
+        mSettingsButton.setVisibility(View.INVISIBLE);
+        mInfoIconButton.setVisibility(View.INVISIBLE);
+
+        switch (mFragmentState) {
+            case FOUND_DEVICE:
+                mInfoIconButton.setVisibility(View.VISIBLE);
+                mConnectButton.setVisibility(View.VISIBLE);
+                break;
+            case PAIRING:
+                mConnectProgressBar.setVisibility(View.VISIBLE);
+                mCancelButton.setVisibility(View.VISIBLE);
+                setBackgroundClickable(false);
+                break;
+            case PAIRED_LAUNCHABLE:
+                mCancelButton.setVisibility(View.VISIBLE);
+                mSetupButton.setVisibility(View.VISIBLE);
+                setBackgroundClickable(true);
+                break;
+            case FAILED:
+                mSettingsButton.setVisibility(View.VISIBLE);
+                setBackgroundClickable(true);
+                break;
+            case NOT_STARTED:
+            case PAIRED_UNLAUNCHABLE:
+            default:
+                mCancelButton.setVisibility(View.VISIBLE);
+                setBackgroundClickable(true);
+        }
+    }
+
+    private void setBackgroundClickable(boolean isClickable) {
+        HalfSheetActivity activity = (HalfSheetActivity) getActivity();
+        if (activity == null) {
+            Log.w(TAG, "setBackgroundClickable: failed to set clickable to " + isClickable
+                    + " because cannot get HalfSheetActivity.");
+            return;
+        }
+        View background = activity.findViewById(R.id.background);
+        if (background == null) {
+            Log.w(TAG, "setBackgroundClickable: failed to set clickable to " + isClickable
+                    + " cannot find background at HalfSheetActivity.");
+            return;
+        }
+        Log.d(TAG, "setBackgroundClickable to " + isClickable);
+        background.setClickable(isClickable);
+    }
+
+    private void invalidateTitles() {
+        String newTitle = getTitle();
+        invalidateTextView(mTitleView, newTitle);
+        String newSubTitle = getSubTitle();
+        invalidateTextView(mSubTitleView, newSubTitle);
+    }
+
+    private void invalidateTextView(TextView textView, String newText) {
+        CharSequence oldText =
+                textView.getTag(TAG_PENDING_TEXT) != null
+                        ? (CharSequence) textView.getTag(TAG_PENDING_TEXT)
+                        : textView.getText();
+        if (TextUtils.equals(oldText, newText)) {
+            return;
+        }
+        if (TextUtils.isEmpty(oldText)) {
+            // First time run. Don't animate since there's nothing to animate from.
+            textView.setText(newText);
+        } else {
+            textView.setTag(TAG_PENDING_TEXT, newText);
+            textView
+                    .animate()
+                    .alpha(0f)
+                    .setDuration(TEXT_ANIMATION_DURATION_MILLISECONDS)
+                    .withEndAction(
+                            () -> {
+                                textView.setText(newText);
+                                textView
+                                        .animate()
+                                        .alpha(1f)
+                                        .setDuration(TEXT_ANIMATION_DURATION_MILLISECONDS);
+                            });
+        }
+    }
+
+    private String getTitle() {
+        switch (mFragmentState) {
+            case PAIRED_LAUNCHABLE:
+                return getString(R.string.fast_pair_title_setup);
+            case FAILED:
+                return getString(R.string.fast_pair_title_fail);
+            case FOUND_DEVICE:
+            case NOT_STARTED:
+            case PAIRED_UNLAUNCHABLE:
+            default:
+                return mScanFastPairStoreItem.getDeviceName();
+        }
+    }
+
+    private String getSubTitle() {
+        switch (mFragmentState) {
+            case PAIRED_LAUNCHABLE:
+                return String.format(
+                        mScanFastPairStoreItem
+                                .getFastPairStrings()
+                                .getPairingFinishedCompanionAppInstalled(),
+                        mScanFastPairStoreItem.getDeviceName());
+            case FAILED:
+                return mScanFastPairStoreItem.getFastPairStrings().getPairingFailDescription();
+            case PAIRED_UNLAUNCHABLE:
+                getString(R.string.fast_pair_device_ready);
+            // fall through
+            case FOUND_DEVICE:
+            case NOT_STARTED:
+                return mScanFastPairStoreItem.getFastPairStrings().getInitialPairingDescription();
+            default:
+                return "";
+        }
+    }
+}
diff --git a/nearby/halfsheet/src/com/android/nearby/halfsheet/fragment/HalfSheetModuleFragment.java b/nearby/halfsheet/src/com/android/nearby/halfsheet/fragment/HalfSheetModuleFragment.java
new file mode 100644
index 0000000..f1db4d0
--- /dev/null
+++ b/nearby/halfsheet/src/com/android/nearby/halfsheet/fragment/HalfSheetModuleFragment.java
@@ -0,0 +1,77 @@
+/*
+ * 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.nearby.halfsheet.fragment;
+
+import static com.android.nearby.halfsheet.HalfSheetActivity.TAG;
+import static com.android.nearby.halfsheet.fragment.HalfSheetModuleFragment.HalfSheetFragmentState.NOT_STARTED;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+
+/** Base class for all of the half sheet fragment. */
+public abstract class HalfSheetModuleFragment extends Fragment {
+
+    static final int TEXT_ANIMATION_DURATION_MILLISECONDS = 200;
+
+    HalfSheetFragmentState mFragmentState = NOT_STARTED;
+
+    @Override
+    public void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+    }
+
+    /** UI states of the half-sheet fragment. */
+    public enum HalfSheetFragmentState {
+        NOT_STARTED, // Initial status
+        FOUND_DEVICE, // When a device is found found from Nearby scan service
+        PAIRING, // When user taps 'Connect' and Fast Pair stars pairing process
+        PAIRED_LAUNCHABLE, // When pair successfully
+        // and we found a launchable companion app installed
+        PAIRED_UNLAUNCHABLE, // When pair successfully
+        // but we cannot find a companion app to launch it
+        FAILED, // When paring was failed
+        FINISHED // When the activity is about to end finished.
+    }
+
+    /**
+     * Returns the {@link HalfSheetFragmentState} to the parent activity.
+     *
+     * <p>Overrides this method if the fragment's state needs to be preserved in the parent
+     * activity.
+     */
+    public HalfSheetFragmentState getFragmentState() {
+        return mFragmentState;
+    }
+
+    void setState(HalfSheetFragmentState state) {
+        Log.v(TAG, "Settings state from " + mFragmentState + " to " + state);
+        mFragmentState = state;
+    }
+
+    /**
+     * Populate data to UI widgets according to the latest {@link HalfSheetFragmentState}.
+     */
+    abstract void invalidateState();
+}
diff --git a/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/BroadcastUtils.java b/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/BroadcastUtils.java
new file mode 100644
index 0000000..467997c
--- /dev/null
+++ b/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/BroadcastUtils.java
@@ -0,0 +1,36 @@
+/*
+ * 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.nearby.halfsheet.utils;
+
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Broadcast util class
+ */
+public class BroadcastUtils {
+
+    /**
+     * Helps send broadcast.
+     */
+    public static void sendBroadcast(Context context, Intent intent) {
+        context.sendBroadcast(intent);
+    }
+
+    private BroadcastUtils() {
+    }
+}
diff --git a/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/FastPairUtils.java b/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/FastPairUtils.java
new file mode 100644
index 0000000..903ea90
--- /dev/null
+++ b/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/FastPairUtils.java
@@ -0,0 +1,151 @@
+/*
+ * 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.nearby.halfsheet.utils;
+
+import static com.android.server.nearby.common.fastpair.service.UserActionHandlerBase.EXTRA_COMPANION_APP;
+import static com.android.server.nearby.fastpair.UserActionHandler.ACTION_FAST_PAIR;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.net.URISyntaxException;
+
+import service.proto.Cache;
+
+/**
+ * Util class in half sheet apk
+ */
+public class FastPairUtils {
+
+    /** FastPair util method check certain app is install on the device or not. */
+    public static boolean isAppInstalled(Context context, String packageName) {
+        try {
+            context.getPackageManager().getPackageInfo(packageName, 0);
+            return true;
+        } catch (PackageManager.NameNotFoundException e) {
+            return false;
+        }
+    }
+
+    /** FastPair util method to properly format the action url extra. */
+    @Nullable
+    public static String getCompanionAppFromActionUrl(String actionUrl) {
+        try {
+            Intent intent = Intent.parseUri(actionUrl, Intent.URI_INTENT_SCHEME);
+            if (!intent.getAction().equals(ACTION_FAST_PAIR)) {
+                Log.e("FastPairUtils", "Companion app launch attempted from malformed action url");
+                return null;
+            }
+            return intent.getStringExtra(EXTRA_COMPANION_APP);
+        } catch (URISyntaxException e) {
+            Log.e("FastPairUtils", "FastPair: fail to get companion app info from discovery item");
+            return null;
+        }
+    }
+
+    /**
+     * Converts {@link service.proto.Cache.StoredDiscoveryItem} from
+     * {@link service.proto.Cache.ScanFastPairStoreItem}
+     */
+    public static Cache.StoredDiscoveryItem convertFrom(Cache.ScanFastPairStoreItem item) {
+        return convertFrom(item, /* isSubsequentPair= */ false);
+    }
+
+    /**
+     * Converts a {@link service.proto.Cache.ScanFastPairStoreItem}
+     * to a {@link service.proto.Cache.StoredDiscoveryItem}.
+     *
+     * <p>This is needed to make the new Fast Pair scanning stack compatible with the rest of the
+     * legacy Fast Pair code.
+     */
+    public static Cache.StoredDiscoveryItem convertFrom(
+            Cache.ScanFastPairStoreItem item, boolean isSubsequentPair) {
+        return Cache.StoredDiscoveryItem.newBuilder()
+                .setId(item.getModelId())
+                .setFirstObservationTimestampMillis(item.getFirstObservationTimestampMillis())
+                .setLastObservationTimestampMillis(item.getLastObservationTimestampMillis())
+                .setType(Cache.NearbyType.NEARBY_DEVICE)
+                .setActionUrl(item.getActionUrl())
+                .setActionUrlType(Cache.ResolvedUrlType.APP)
+                .setTitle(
+                        isSubsequentPair
+                                ? item.getFastPairStrings().getTapToPairWithoutAccount()
+                                : item.getDeviceName())
+                .setMacAddress(item.getAddress())
+                .setState(Cache.StoredDiscoveryItem.State.STATE_ENABLED)
+                .setTriggerId(item.getModelId())
+                .setIconPng(item.getIconPng())
+                .setIconFifeUrl(item.getIconFifeUrl())
+                .setDescription(
+                        isSubsequentPair
+                                ? item.getDeviceName()
+                                : item.getFastPairStrings().getTapToPairWithoutAccount())
+                .setAuthenticationPublicKeySecp256R1(item.getAntiSpoofingPublicKey())
+                .setCompanionDetail(item.getCompanionDetail())
+                .setFastPairStrings(item.getFastPairStrings())
+                .setFastPairInformation(
+                        Cache.FastPairInformation.newBuilder()
+                                .setDataOnlyConnection(item.getDataOnlyConnection())
+                                .setTrueWirelessImages(item.getTrueWirelessImages())
+                                .setAssistantSupported(item.getAssistantSupported())
+                                .setCompanyName(item.getCompanyName()))
+                .build();
+    }
+
+    /**
+     * Returns true the application is installed and can be opened on device.
+     */
+    public static boolean isLaunchable(@NonNull Context context, String companionApp) {
+        return isAppInstalled(context, companionApp)
+                && createCompanionAppIntent(context, companionApp, null) != null;
+    }
+
+    /**
+     * Returns an intent to launch given the package name and bluetooth address (if provided).
+     * Returns null if no such an intent can be found.
+     */
+    @Nullable
+    public static Intent createCompanionAppIntent(@NonNull Context context, String packageName,
+            @Nullable String address) {
+        Intent intent = context.getPackageManager().getLaunchIntentForPackage(packageName);
+        if (intent == null) {
+            return null;
+        }
+        if (address != null) {
+            BluetoothAdapter adapter = getBluetoothAdapter(context);
+            if (adapter != null) {
+                intent.putExtra(BluetoothDevice.EXTRA_DEVICE, adapter.getRemoteDevice(address));
+            }
+        }
+        return intent;
+    }
+
+    @Nullable
+    private static BluetoothAdapter getBluetoothAdapter(@NonNull Context context) {
+        BluetoothManager bluetoothManager = context.getSystemService(BluetoothManager.class);
+        return bluetoothManager == null ? null : bluetoothManager.getAdapter();
+    }
+
+    private FastPairUtils() {}
+}
diff --git a/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/IconUtils.java b/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/IconUtils.java
new file mode 100644
index 0000000..218c756
--- /dev/null
+++ b/nearby/halfsheet/src/com/android/nearby/halfsheet/utils/IconUtils.java
@@ -0,0 +1,133 @@
+/*
+ * 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.nearby.halfsheet.utils;
+
+import static com.android.nearby.halfsheet.HalfSheetActivity.TAG;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.core.graphics.ColorUtils;
+
+/**
+ * Utility class for icon size verification.
+ */
+public class IconUtils {
+
+    private static final float NOTIFICATION_BACKGROUND_PADDING_PERCENT = 0.125f;
+    private static final float NOTIFICATION_BACKGROUND_ALPHA = 0.7f;
+    private static final int MIN_ICON_SIZE = 16;
+    private static final int DESIRED_ICON_SIZE = 32;
+
+    /**
+     * Verify that the icon is non null and falls in the small bucket. Just because an icon isn't
+     * small doesn't guarantee it is large or exists.
+     */
+    public static boolean isIconSizedSmall(@Nullable Bitmap bitmap) {
+        if (bitmap == null) {
+            return false;
+        }
+        return bitmap.getWidth() >= MIN_ICON_SIZE
+                && bitmap.getWidth() < DESIRED_ICON_SIZE
+                && bitmap.getHeight() >= MIN_ICON_SIZE
+                && bitmap.getHeight() < DESIRED_ICON_SIZE;
+    }
+
+    /**
+     * Verify that the icon is non null and falls in the regular / default size bucket. Doesn't
+     * guarantee if not regular then it is small.
+     */
+    static boolean isIconSizedRegular(@Nullable Bitmap bitmap) {
+        if (bitmap == null) {
+            return false;
+        }
+        return bitmap.getWidth() >= DESIRED_ICON_SIZE && bitmap.getHeight() >= DESIRED_ICON_SIZE;
+    }
+
+    /**
+     * All icons that are sized correctly (larger than the MIN_ICON_SIZE icon size)
+     * are resize on the server to the DESIRED_ICON_SIZE icon size so that
+     * they appear correct.
+     */
+    public static boolean isIconSizeCorrect(@Nullable Bitmap bitmap) {
+        if (bitmap == null) {
+            return false;
+        }
+        return isIconSizedSmall(bitmap) || isIconSizedRegular(bitmap);
+    }
+
+    /**
+     * Returns the bitmap from the byte array. Returns null if cannot decode or not in correct size.
+     */
+    @Nullable
+    public static Bitmap getIcon(byte[] imageData, int size) {
+        try {
+            Bitmap icon =
+                    BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, size);
+            if (IconUtils.isIconSizeCorrect(icon)) {
+                // Do not add background for Half Sheet.
+                return IconUtils.addWhiteCircleBackground(icon);
+            }
+        } catch (OutOfMemoryError e) {
+            Log.w(TAG, "getIcon: Failed to decode icon, returning null.", e);
+        }
+        return null;
+    }
+
+    /** Adds a circular, white background to the bitmap. */
+    @Nullable
+    public static Bitmap addWhiteCircleBackground(Bitmap bitmap) {
+        if (bitmap == null) {
+            Log.w(TAG, "addWhiteCircleBackground: Bitmap is null, not adding background.");
+            return null;
+        }
+
+        if (bitmap.getWidth() != bitmap.getHeight()) {
+            Log.w(TAG, "addWhiteCircleBackground: Bitmap dimensions not square. Skipping"
+                    + "adding background.");
+            return bitmap;
+        }
+
+        int padding = (int) (bitmap.getWidth() * NOTIFICATION_BACKGROUND_PADDING_PERCENT);
+        Bitmap bitmapWithBackground =
+                Bitmap.createBitmap(
+                        bitmap.getWidth() + (2 * padding),
+                        bitmap.getHeight() + (2 * padding),
+                        bitmap.getConfig());
+        Canvas canvas = new Canvas(bitmapWithBackground);
+        Paint paint = new Paint();
+        paint.setColor(
+                ColorUtils.setAlphaComponent(
+                        Color.WHITE, (int) (255 * NOTIFICATION_BACKGROUND_ALPHA)));
+        paint.setStyle(Paint.Style.FILL);
+        paint.setAntiAlias(true);
+        canvas.drawCircle(
+                bitmapWithBackground.getWidth() / 2,
+                bitmapWithBackground.getHeight() / 2,
+                bitmapWithBackground.getWidth() / 2,
+                paint);
+        canvas.drawBitmap(bitmap, padding, padding, null);
+
+        return bitmapWithBackground;
+    }
+}
+
diff --git a/nearby/service-src/com/android/server/nearby/NearbyService.java b/nearby/service-src/com/android/server/nearby/NearbyService.java
deleted file mode 100644
index 88752cc..0000000
--- a/nearby/service-src/com/android/server/nearby/NearbyService.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.nearby;
-
-import android.content.Context;
-import android.os.Binder;
-
-/**
- * Stub NearbyService class, used until NearbyService code is available in all branches.
- *
- * This can be published as an empty service in branches that use it.
- */
-public final class NearbyService extends Binder {
-    public NearbyService(Context ctx) {
-        throw new UnsupportedOperationException("This is a stub service");
-    }
-
-    /** Called by the service initializer on each boot phase */
-    public void onBootPhase(int phase) {
-        // Do nothing
-    }
-}
diff --git a/nearby/service/Android.bp b/nearby/service/Android.bp
new file mode 100644
index 0000000..802e2c8
--- /dev/null
+++ b/nearby/service/Android.bp
@@ -0,0 +1,98 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+filegroup {
+    name: "nearby-service-srcs",
+    srcs: [
+        "java/**/*.java",
+        ":statslog-nearby-java-gen",
+    ],
+}
+
+filegroup {
+    name: "nearby-service-string-res",
+    srcs: [
+        "java/**/Constant.java",
+        "java/**/UserActionHandlerBase.java",
+        "java/**/UserActionHandler.java",
+        "java/**/FastPairConstants.java",
+    ],
+}
+
+java_library {
+    name: "nearby-service-string",
+    srcs: [":nearby-service-string-res"],
+    libs: ["framework-bluetooth"],
+    sdk_version: "module_current",
+}
+
+
+// Main lib for nearby services.
+java_library {
+    name: "service-nearby-pre-jarjar",
+    srcs: [":nearby-service-srcs"],
+
+    defaults: [
+        "framework-system-server-module-defaults"
+    ],
+    libs: [
+        "framework-bluetooth.stubs.module_lib", // TODO(b/215722418): Change to framework-bluetooth once fixed
+        "error_prone_annotations",
+        "framework-connectivity-t.impl",
+        "framework-statsd.stubs.module_lib",
+    ],
+    static_libs: [
+        "androidx.annotation_annotation",
+        "androidx.core_core",
+        "androidx.localbroadcastmanager_localbroadcastmanager",
+        "guava",
+        "libprotobuf-java-lite",
+        "fast-pair-lite-protos",
+        "modules-utils-build",
+        "modules-utils-handlerexecutor",
+        "modules-utils-preconditions",
+        "modules-utils-backgroundthread",
+        "presence-lite-protos",
+    ],
+    sdk_version: "system_server_current",
+    // This is included in service-connectivity which is 30+
+    // TODO: allow APEXes to have service jars with higher min_sdk than the APEX
+    // (service-connectivity is only used on 31+) and use 31 here
+    min_sdk_version: "30",
+
+    installable: true,
+    dex_preopt: {
+        enabled: false,
+        app_image: false,
+    },
+    visibility: [
+        "//packages/modules/Nearby/apex",
+    ],
+    apex_available: [
+        "com.android.tethering",
+    ],
+}
+
+genrule {
+    name: "statslog-nearby-java-gen",
+    tools: ["stats-log-api-gen"],
+    cmd: "$(location stats-log-api-gen) --java $(out) --module nearby " +
+         " --javaPackage com.android.server.nearby.proto --javaClass NearbyStatsLog" +
+         " --minApiLevel 33",
+    out: ["com/android/server/nearby/proto/NearbyStatsLog.java"],
+}
diff --git a/nearby/service/java/com/android/server/nearby/NearbyConfiguration.java b/nearby/service/java/com/android/server/nearby/NearbyConfiguration.java
new file mode 100644
index 0000000..8fdac87
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/NearbyConfiguration.java
@@ -0,0 +1,58 @@
+/*
+ * 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;
+
+import android.provider.DeviceConfig;
+
+import androidx.annotation.VisibleForTesting;
+
+/**
+ * A utility class for encapsulating Nearby feature flag configurations.
+ */
+public class NearbyConfiguration {
+
+    /**
+     * Flag use to enable presence legacy broadcast.
+     */
+    public static final String NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY =
+            "nearby_enable_presence_broadcast_legacy";
+
+    private boolean mEnablePresenceBroadcastLegacy;
+
+    public NearbyConfiguration() {
+        mEnablePresenceBroadcastLegacy = getDeviceConfigBoolean(
+                NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY, false /* defaultValue */);
+
+    }
+
+    /**
+     * Returns whether broadcasting legacy presence spec is enabled.
+     */
+    public boolean isPresenceBroadcastLegacyEnabled() {
+        return mEnablePresenceBroadcastLegacy;
+    }
+
+    private boolean getDeviceConfigBoolean(final String name, final boolean defaultValue) {
+        final String value = getDeviceConfigProperty(name);
+        return value != null ? Boolean.parseBoolean(value) : defaultValue;
+    }
+
+    @VisibleForTesting
+    protected String getDeviceConfigProperty(String name) {
+        return DeviceConfig.getProperty(DeviceConfig.NAMESPACE_TETHERING, name);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/NearbyService.java b/nearby/service/java/com/android/server/nearby/NearbyService.java
new file mode 100644
index 0000000..e3e5b5d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/NearbyService.java
@@ -0,0 +1,180 @@
+/*
+ * 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;
+
+import static com.android.server.SystemService.PHASE_BOOT_COMPLETED;
+import static com.android.server.SystemService.PHASE_THIRD_PARTY_APPS_CAN_START;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.location.ContextHubManager;
+import android.nearby.BroadcastRequestParcelable;
+import android.nearby.IBroadcastListener;
+import android.nearby.INearbyManager;
+import android.nearby.IScanListener;
+import android.nearby.NearbyManager;
+import android.nearby.ScanRequest;
+import android.util.Log;
+
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+import com.android.server.nearby.fastpair.FastPairManager;
+import com.android.server.nearby.injector.ContextHubManagerAdapter;
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.presence.PresenceManager;
+import com.android.server.nearby.provider.BroadcastProviderManager;
+import com.android.server.nearby.provider.DiscoveryProviderManager;
+import com.android.server.nearby.provider.FastPairDataProvider;
+
+/** Service implementing nearby functionality. */
+public class NearbyService extends INearbyManager.Stub {
+    public static final String TAG = "NearbyService";
+
+    private final Context mContext;
+    private final SystemInjector mSystemInjector;
+    private final FastPairManager mFastPairManager;
+    private final PresenceManager mPresenceManager;
+    private final BroadcastReceiver mBluetoothReceiver =
+            new BroadcastReceiver() {
+                @Override
+                public void onReceive(Context context, Intent intent) {
+                    int state =
+                            intent.getIntExtra(
+                                    BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR);
+                    if (state == BluetoothAdapter.STATE_ON) {
+                        if (mSystemInjector != null) {
+                            // Have to do this logic in listener. Even during PHASE_BOOT_COMPLETED
+                            // phase, BluetoothAdapter is not null, the BleScanner is null.
+                            Log.v(TAG, "Initiating BluetoothAdapter when Bluetooth is turned on.");
+                            mSystemInjector.initializeBluetoothAdapter();
+                        }
+                    }
+                }
+            };
+    private DiscoveryProviderManager mProviderManager;
+    private BroadcastProviderManager mBroadcastProviderManager;
+
+    public NearbyService(Context context) {
+        mContext = context;
+        mSystemInjector = new SystemInjector(context);
+        mProviderManager = new DiscoveryProviderManager(context, mSystemInjector);
+        mBroadcastProviderManager = new BroadcastProviderManager(context, mSystemInjector);
+        final LocatorContextWrapper lcw = new LocatorContextWrapper(context, null);
+        mFastPairManager = new FastPairManager(lcw);
+        mPresenceManager = new PresenceManager(lcw);
+    }
+
+    @Override
+    @NearbyManager.ScanStatus
+    public int registerScanListener(ScanRequest scanRequest, IScanListener listener) {
+        if (mProviderManager.registerScanListener(scanRequest, listener)) {
+            return NearbyManager.ScanStatus.SUCCESS;
+        }
+        return NearbyManager.ScanStatus.ERROR;
+    }
+
+    @Override
+    public void unregisterScanListener(IScanListener listener) {
+        mProviderManager.unregisterScanListener(listener);
+    }
+
+    @Override
+    public void startBroadcast(BroadcastRequestParcelable broadcastRequestParcelable,
+            IBroadcastListener listener) {
+        mBroadcastProviderManager.startBroadcast(
+                broadcastRequestParcelable.getBroadcastRequest(),
+                listener);
+    }
+
+    @Override
+    public void stopBroadcast(IBroadcastListener listener) {
+        mBroadcastProviderManager.stopBroadcast(listener);
+    }
+
+    /**
+     * Called by the service initializer.
+     *
+     * <p>{@see com.android.server.SystemService#onBootPhase}.
+     */
+    public void onBootPhase(int phase) {
+        switch (phase) {
+            case PHASE_THIRD_PARTY_APPS_CAN_START:
+                // Ensures that a fast pair data provider exists which will work in direct boot.
+                FastPairDataProvider.init(mContext);
+                break;
+            case PHASE_BOOT_COMPLETED:
+                // The nearby service must be functioning after this boot phase.
+                mSystemInjector.initializeBluetoothAdapter();
+                mContext.registerReceiver(
+                        mBluetoothReceiver,
+                        new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
+                mFastPairManager.initiate();
+                // Initialize ContextManager for CHRE scan.
+                mSystemInjector.initializeContextHubManagerAdapter();
+                mPresenceManager.initiate();
+                break;
+        }
+    }
+
+    private static final class SystemInjector implements Injector {
+        private final Context mContext;
+        @Nullable private BluetoothAdapter mBluetoothAdapter;
+        @Nullable private ContextHubManagerAdapter mContextHubManagerAdapter;
+
+        SystemInjector(Context context) {
+            mContext = context;
+        }
+
+        @Override
+        @Nullable
+        public BluetoothAdapter getBluetoothAdapter() {
+            return mBluetoothAdapter;
+        }
+
+        @Override
+        @Nullable
+        public ContextHubManagerAdapter getContextHubManagerAdapter() {
+            return mContextHubManagerAdapter;
+        }
+
+        synchronized void initializeBluetoothAdapter() {
+            if (mBluetoothAdapter != null) {
+                return;
+            }
+            BluetoothManager manager = mContext.getSystemService(BluetoothManager.class);
+            if (manager == null) {
+                return;
+            }
+            mBluetoothAdapter = manager.getAdapter();
+        }
+
+        synchronized void initializeContextHubManagerAdapter() {
+            if (mContextHubManagerAdapter != null) {
+                return;
+            }
+            ContextHubManager manager = mContext.getSystemService(ContextHubManager.class);
+            if (manager == null) {
+                return;
+            }
+            mContextHubManagerAdapter = new ContextHubManagerAdapter(manager);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/BleFilter.java b/nearby/service/java/com/android/server/nearby/common/ble/BleFilter.java
new file mode 100644
index 0000000..23d5170
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/BleFilter.java
@@ -0,0 +1,746 @@
+/*
+ * 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.common.ble;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.le.ScanFilter;
+import android.os.Parcel;
+import android.os.ParcelUuid;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Criteria for filtering BLE devices. A {@link BleFilter} allows clients to restrict BLE devices to
+ * only those that are of interest to them.
+ *
+ *
+ * <p>Current filtering on the following fields are supported:
+ * <li>Service UUIDs which identify the bluetooth gatt services running on the device.
+ * <li>Name of remote Bluetooth LE device.
+ * <li>Mac address of the remote device.
+ * <li>Service data which is the data associated with a service.
+ * <li>Manufacturer specific data which is the data associated with a particular manufacturer.
+ *
+ * @see BleSighting
+ */
+public final class BleFilter implements Parcelable {
+
+    @Nullable
+    private String mDeviceName;
+
+    @Nullable
+    private String mDeviceAddress;
+
+    @Nullable
+    private ParcelUuid mServiceUuid;
+
+    @Nullable
+    private ParcelUuid mServiceUuidMask;
+
+    @Nullable
+    private ParcelUuid mServiceDataUuid;
+
+    @Nullable
+    private byte[] mServiceData;
+
+    @Nullable
+    private byte[] mServiceDataMask;
+
+    private int mManufacturerId;
+
+    @Nullable
+    private byte[] mManufacturerData;
+
+    @Nullable
+    private byte[] mManufacturerDataMask;
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    BleFilter() {
+    }
+
+    BleFilter(
+            @Nullable String deviceName,
+            @Nullable String deviceAddress,
+            @Nullable ParcelUuid serviceUuid,
+            @Nullable ParcelUuid serviceUuidMask,
+            @Nullable ParcelUuid serviceDataUuid,
+            @Nullable byte[] serviceData,
+            @Nullable byte[] serviceDataMask,
+            int manufacturerId,
+            @Nullable byte[] manufacturerData,
+            @Nullable byte[] manufacturerDataMask) {
+        this.mDeviceName = deviceName;
+        this.mDeviceAddress = deviceAddress;
+        this.mServiceUuid = serviceUuid;
+        this.mServiceUuidMask = serviceUuidMask;
+        this.mServiceDataUuid = serviceDataUuid;
+        this.mServiceData = serviceData;
+        this.mServiceDataMask = serviceDataMask;
+        this.mManufacturerId = manufacturerId;
+        this.mManufacturerData = manufacturerData;
+        this.mManufacturerDataMask = manufacturerDataMask;
+    }
+
+    public static final Parcelable.Creator<BleFilter> CREATOR = new Creator<BleFilter>() {
+        @Override
+        public BleFilter createFromParcel(Parcel source) {
+            BleFilter nBleFilter = new BleFilter();
+            nBleFilter.mDeviceName = source.readString();
+            nBleFilter.mDeviceAddress = source.readString();
+            nBleFilter.mManufacturerId = source.readInt();
+            nBleFilter.mManufacturerData = source.marshall();
+            nBleFilter.mManufacturerDataMask = source.marshall();
+            nBleFilter.mServiceDataUuid = source.readParcelable(null);
+            nBleFilter.mServiceData = source.marshall();
+            nBleFilter.mServiceDataMask = source.marshall();
+            nBleFilter.mServiceUuid = source.readParcelable(null);
+            nBleFilter.mServiceUuidMask = source.readParcelable(null);
+            return nBleFilter;
+        }
+
+        @Override
+        public BleFilter[] newArray(int size) {
+            return new BleFilter[size];
+        }
+    };
+
+
+    /** Returns the filter set on the device name field of Bluetooth advertisement data. */
+    @Nullable
+    public String getDeviceName() {
+        return mDeviceName;
+    }
+
+    /** Returns the filter set on the service uuid. */
+    @Nullable
+    public ParcelUuid getServiceUuid() {
+        return mServiceUuid;
+    }
+
+    /** Returns the mask for the service uuid. */
+    @Nullable
+    public ParcelUuid getServiceUuidMask() {
+        return mServiceUuidMask;
+    }
+
+    /** Returns the filter set on the device address. */
+    @Nullable
+    public String getDeviceAddress() {
+        return mDeviceAddress;
+    }
+
+    /** Returns the filter set on the service data. */
+    @Nullable
+    public byte[] getServiceData() {
+        return mServiceData;
+    }
+
+    /** Returns the mask for the service data. */
+    @Nullable
+    public byte[] getServiceDataMask() {
+        return mServiceDataMask;
+    }
+
+    /** Returns the filter set on the service data uuid. */
+    @Nullable
+    public ParcelUuid getServiceDataUuid() {
+        return mServiceDataUuid;
+    }
+
+    /** Returns the manufacturer id. -1 if the manufacturer filter is not set. */
+    public int getManufacturerId() {
+        return mManufacturerId;
+    }
+
+    /** Returns the filter set on the manufacturer data. */
+    @Nullable
+    public byte[] getManufacturerData() {
+        return mManufacturerData;
+    }
+
+    /** Returns the mask for the manufacturer data. */
+    @Nullable
+    public byte[] getManufacturerDataMask() {
+        return mManufacturerDataMask;
+    }
+
+    /**
+     * Check if the filter matches a {@code BleSighting}. A BLE sighting is considered as a match if
+     * it matches all the field filters.
+     */
+    public boolean matches(@Nullable BleSighting bleSighting) {
+        if (bleSighting == null) {
+            return false;
+        }
+        BluetoothDevice device = bleSighting.getDevice();
+        // Device match.
+        if (mDeviceAddress != null && (device == null || !mDeviceAddress.equals(
+                device.getAddress()))) {
+            return false;
+        }
+
+        BleRecord bleRecord = bleSighting.getBleRecord();
+
+        // Scan record is null but there exist filters on it.
+        if (bleRecord == null
+                && (mDeviceName != null
+                || mServiceUuid != null
+                || mManufacturerData != null
+                || mServiceData != null)) {
+            return false;
+        }
+
+        // Local name match.
+        if (mDeviceName != null && !mDeviceName.equals(bleRecord.getDeviceName())) {
+            return false;
+        }
+
+        // UUID match.
+        if (mServiceUuid != null
+                && !matchesServiceUuids(mServiceUuid, mServiceUuidMask,
+                bleRecord.getServiceUuids())) {
+            return false;
+        }
+
+        // Service data match
+        if (mServiceDataUuid != null
+                && !matchesPartialData(
+                mServiceData, mServiceDataMask, bleRecord.getServiceData(mServiceDataUuid))) {
+            return false;
+        }
+
+        // Manufacturer data match.
+        if (mManufacturerId >= 0
+                && !matchesPartialData(
+                mManufacturerData,
+                mManufacturerDataMask,
+                bleRecord.getManufacturerSpecificData(mManufacturerId))) {
+            return false;
+        }
+
+        // All filters match.
+        return true;
+    }
+
+    /**
+     * Determines if the characteristics of this filter are a superset of the characteristics of the
+     * given filter.
+     */
+    public boolean isSuperset(@Nullable BleFilter bleFilter) {
+        if (bleFilter == null) {
+            return false;
+        }
+
+        if (equals(bleFilter)) {
+            return true;
+        }
+
+        // Verify device address matches.
+        if (mDeviceAddress != null && !mDeviceAddress.equals(bleFilter.getDeviceAddress())) {
+            return false;
+        }
+
+        // Verify device name matches.
+        if (mDeviceName != null && !mDeviceName.equals(bleFilter.getDeviceName())) {
+            return false;
+        }
+
+        // Verify UUID is a superset.
+        if (mServiceUuid != null
+                && !serviceUuidIsSuperset(
+                mServiceUuid,
+                mServiceUuidMask,
+                bleFilter.getServiceUuid(),
+                bleFilter.getServiceUuidMask())) {
+            return false;
+        }
+
+        // Verify service data is a superset.
+        if (mServiceDataUuid != null
+                && (!mServiceDataUuid.equals(bleFilter.getServiceDataUuid())
+                || !partialDataIsSuperset(
+                mServiceData,
+                mServiceDataMask,
+                bleFilter.getServiceData(),
+                bleFilter.getServiceDataMask()))) {
+            return false;
+        }
+
+        // Verify manufacturer data is a superset.
+        if (mManufacturerId >= 0
+                && (mManufacturerId != bleFilter.getManufacturerId()
+                || !partialDataIsSuperset(
+                mManufacturerData,
+                mManufacturerDataMask,
+                bleFilter.getManufacturerData(),
+                bleFilter.getManufacturerDataMask()))) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /** Determines if the first uuid and mask are a superset of the second uuid and mask. */
+    private static boolean serviceUuidIsSuperset(
+            @Nullable ParcelUuid uuid1,
+            @Nullable ParcelUuid uuidMask1,
+            @Nullable ParcelUuid uuid2,
+            @Nullable ParcelUuid uuidMask2) {
+        // First uuid1 is null so it can match any service UUID.
+        if (uuid1 == null) {
+            return true;
+        }
+
+        // uuid2 is a superset of uuid1, but not the other way around.
+        if (uuid2 == null) {
+            return false;
+        }
+
+        // Without a mask, the uuids must match.
+        if (uuidMask1 == null) {
+            return uuid1.equals(uuid2);
+        }
+
+        // Mask2 should be at least as specific as mask1.
+        if (uuidMask2 != null) {
+            long uuid1MostSig = uuidMask1.getUuid().getMostSignificantBits();
+            long uuid1LeastSig = uuidMask1.getUuid().getLeastSignificantBits();
+            long uuid2MostSig = uuidMask2.getUuid().getMostSignificantBits();
+            long uuid2LeastSig = uuidMask2.getUuid().getLeastSignificantBits();
+            if (((uuid1MostSig & uuid2MostSig) != uuid1MostSig)
+                    || ((uuid1LeastSig & uuid2LeastSig) != uuid1LeastSig)) {
+                return false;
+            }
+        }
+
+        if (!matchesServiceUuids(uuid1, uuidMask1, Arrays.asList(uuid2))) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /** Determines if the first data and mask are the superset of the second data and mask. */
+    private static boolean partialDataIsSuperset(
+            @Nullable byte[] data1,
+            @Nullable byte[] dataMask1,
+            @Nullable byte[] data2,
+            @Nullable byte[] dataMask2) {
+        if (Arrays.equals(data1, data2) && Arrays.equals(dataMask1, dataMask2)) {
+            return true;
+        }
+
+        if (data1 == null) {
+            return true;
+        }
+
+        if (data2 == null) {
+            return false;
+        }
+
+        // Mask2 should be at least as specific as mask1.
+        if (dataMask1 != null && dataMask2 != null) {
+            for (int i = 0, j = 0; i < dataMask1.length && j < dataMask2.length; i++, j++) {
+                if ((dataMask1[i] & dataMask2[j]) != dataMask1[i]) {
+                    return false;
+                }
+            }
+        }
+
+        if (!matchesPartialData(data1, dataMask1, data2)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /** Check if the uuid pattern is contained in a list of parcel uuids. */
+    private static boolean matchesServiceUuids(
+            @Nullable ParcelUuid uuid, @Nullable ParcelUuid parcelUuidMask,
+            List<ParcelUuid> uuids) {
+        if (uuid == null) {
+            // No service uuid filter has been set, so there's a match.
+            return true;
+        }
+
+        UUID uuidMask = parcelUuidMask == null ? null : parcelUuidMask.getUuid();
+        for (ParcelUuid parcelUuid : uuids) {
+            if (matchesServiceUuid(uuid.getUuid(), uuidMask, parcelUuid.getUuid())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Check if the uuid pattern matches the particular service uuid. */
+    private static boolean matchesServiceUuid(UUID uuid, @Nullable UUID mask, UUID data) {
+        if (mask == null) {
+            return uuid.equals(data);
+        }
+        if ((uuid.getLeastSignificantBits() & mask.getLeastSignificantBits())
+                != (data.getLeastSignificantBits() & mask.getLeastSignificantBits())) {
+            return false;
+        }
+        return ((uuid.getMostSignificantBits() & mask.getMostSignificantBits())
+                == (data.getMostSignificantBits() & mask.getMostSignificantBits()));
+    }
+
+    /**
+     * Check whether the data pattern matches the parsed data. Assumes that {@code data} and {@code
+     * dataMask} have the same length.
+     */
+    /* package */
+    static boolean matchesPartialData(
+            @Nullable byte[] data, @Nullable byte[] dataMask, @Nullable byte[] parsedData) {
+        if (data == null || parsedData == null || parsedData.length < data.length) {
+            return false;
+        }
+        if (dataMask == null) {
+            for (int i = 0; i < data.length; ++i) {
+                if (parsedData[i] != data[i]) {
+                    return false;
+                }
+            }
+            return true;
+        }
+        for (int i = 0; i < data.length; ++i) {
+            if ((dataMask[i] & parsedData[i]) != (dataMask[i] & data[i])) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public String toString() {
+        return "BleFilter [deviceName="
+                + mDeviceName
+                + ", deviceAddress="
+                + mDeviceAddress
+                + ", uuid="
+                + mServiceUuid
+                + ", uuidMask="
+                + mServiceUuidMask
+                + ", serviceDataUuid="
+                + mServiceDataUuid
+                + ", serviceData="
+                + Arrays.toString(mServiceData)
+                + ", serviceDataMask="
+                + Arrays.toString(mServiceDataMask)
+                + ", manufacturerId="
+                + mManufacturerId
+                + ", manufacturerData="
+                + Arrays.toString(mManufacturerData)
+                + ", manufacturerDataMask="
+                + Arrays.toString(mManufacturerDataMask)
+                + "]";
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        out.writeString(mDeviceName);
+        out.writeString(mDeviceAddress);
+        out.writeInt(mManufacturerId);
+        out.writeByteArray(mManufacturerData);
+        out.writeByteArray(mManufacturerDataMask);
+        out.writeParcelable(mServiceDataUuid, flags);
+        out.writeByteArray(mServiceData);
+        out.writeByteArray(mServiceDataMask);
+        out.writeParcelable(mServiceUuid, flags);
+        out.writeParcelable(mServiceUuidMask, flags);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(
+                mDeviceName,
+                mDeviceAddress,
+                mManufacturerId,
+                Arrays.hashCode(mManufacturerData),
+                Arrays.hashCode(mManufacturerDataMask),
+                mServiceDataUuid,
+                Arrays.hashCode(mServiceData),
+                Arrays.hashCode(mServiceDataMask),
+                mServiceUuid,
+                mServiceUuidMask);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null || getClass() != obj.getClass()) {
+            return false;
+        }
+        BleFilter other = (BleFilter) obj;
+        return mDeviceName.equals(other.mDeviceName)
+                && mDeviceAddress.equals(other.mDeviceAddress)
+                && mManufacturerId == other.mManufacturerId
+                && Arrays.equals(mManufacturerData, other.mManufacturerData)
+                && Arrays.equals(mManufacturerDataMask, other.mManufacturerDataMask)
+                && mServiceDataUuid.equals(other.mServiceDataUuid)
+                && Arrays.equals(mServiceData, other.mServiceData)
+                && Arrays.equals(mServiceDataMask, other.mServiceDataMask)
+                && mServiceUuid.equals(other.mServiceUuid)
+                && mServiceUuidMask.equals(other.mServiceUuidMask);
+    }
+
+    /** Builder class for {@link BleFilter}. */
+    public static final class Builder {
+
+        private String mDeviceName;
+        private String mDeviceAddress;
+
+        @Nullable
+        private ParcelUuid mServiceUuid;
+        @Nullable
+        private ParcelUuid mUuidMask;
+
+        private ParcelUuid mServiceDataUuid;
+        @Nullable
+        private byte[] mServiceData;
+        @Nullable
+        private byte[] mServiceDataMask;
+
+        private int mManufacturerId = -1;
+        private byte[] mManufacturerData;
+        @Nullable
+        private byte[] mManufacturerDataMask;
+
+        /** Set filter on device name. */
+        public Builder setDeviceName(String deviceName) {
+            this.mDeviceName = deviceName;
+            return this;
+        }
+
+        /**
+         * Set filter on device address.
+         *
+         * @param deviceAddress The device Bluetooth address for the filter. It needs to be in the
+         *                      format of "01:02:03:AB:CD:EF". The device address can be validated
+         *                      using {@link
+         *                      BluetoothAdapter#checkBluetoothAddress}.
+         * @throws IllegalArgumentException If the {@code deviceAddress} is invalid.
+         */
+        public Builder setDeviceAddress(String deviceAddress) {
+            if (!BluetoothAdapter.checkBluetoothAddress(deviceAddress)) {
+                throw new IllegalArgumentException("invalid device address " + deviceAddress);
+            }
+            this.mDeviceAddress = deviceAddress;
+            return this;
+        }
+
+        /** Set filter on service uuid. */
+        public Builder setServiceUuid(@Nullable ParcelUuid serviceUuid) {
+            this.mServiceUuid = serviceUuid;
+            mUuidMask = null; // clear uuid mask
+            return this;
+        }
+
+        /**
+         * Set filter on partial service uuid. The {@code uuidMask} is the bit mask for the {@code
+         * serviceUuid}. Set any bit in the mask to 1 to indicate a match is needed for the bit in
+         * {@code serviceUuid}, and 0 to ignore that bit.
+         *
+         * @throws IllegalArgumentException If {@code serviceUuid} is {@code null} but {@code
+         *                                  uuidMask}
+         *                                  is not {@code null}.
+         */
+        public Builder setServiceUuid(@Nullable ParcelUuid serviceUuid,
+                @Nullable ParcelUuid uuidMask) {
+            if (uuidMask != null && serviceUuid == null) {
+                throw new IllegalArgumentException("uuid is null while uuidMask is not null!");
+            }
+            this.mServiceUuid = serviceUuid;
+            this.mUuidMask = uuidMask;
+            return this;
+        }
+
+        /**
+         * Set filtering on service data.
+         */
+        public Builder setServiceData(ParcelUuid serviceDataUuid, @Nullable byte[] serviceData) {
+            this.mServiceDataUuid = serviceDataUuid;
+            this.mServiceData = serviceData;
+            mServiceDataMask = null; // clear service data mask
+            return this;
+        }
+
+        /**
+         * Set partial filter on service data. For any bit in the mask, set it to 1 if it needs to
+         * match
+         * the one in service data, otherwise set it to 0 to ignore that bit.
+         *
+         * <p>The {@code serviceDataMask} must have the same length of the {@code serviceData}.
+         *
+         * @throws IllegalArgumentException If {@code serviceDataMask} is {@code null} while {@code
+         *                                  serviceData} is not or {@code serviceDataMask} and
+         *                                  {@code serviceData} has different
+         *                                  length.
+         */
+        public Builder setServiceData(
+                ParcelUuid serviceDataUuid,
+                @Nullable byte[] serviceData,
+                @Nullable byte[] serviceDataMask) {
+            if (serviceDataMask != null) {
+                if (serviceData == null) {
+                    throw new IllegalArgumentException(
+                            "serviceData is null while serviceDataMask is not null");
+                }
+                // Since the serviceDataMask is a bit mask for serviceData, the lengths of the two
+                // byte array need to be the same.
+                if (serviceData.length != serviceDataMask.length) {
+                    throw new IllegalArgumentException(
+                            "size mismatch for service data and service data mask");
+                }
+            }
+            this.mServiceDataUuid = serviceDataUuid;
+            this.mServiceData = serviceData;
+            this.mServiceDataMask = serviceDataMask;
+            return this;
+        }
+
+        /**
+         * Set filter on on manufacturerData. A negative manufacturerId is considered as invalid id.
+         *
+         * <p>Note the first two bytes of the {@code manufacturerData} is the manufacturerId.
+         *
+         * @throws IllegalArgumentException If the {@code manufacturerId} is invalid.
+         */
+        public Builder setManufacturerData(int manufacturerId, @Nullable byte[] manufacturerData) {
+            return setManufacturerData(manufacturerId, manufacturerData, null /* mask */);
+        }
+
+        /**
+         * Set filter on partial manufacture data. For any bit in the mask, set it to 1 if it needs
+         * to
+         * match the one in manufacturer data, otherwise set it to 0.
+         *
+         * <p>The {@code manufacturerDataMask} must have the same length of {@code
+         * manufacturerData}.
+         *
+         * @throws IllegalArgumentException If the {@code manufacturerId} is invalid, or {@code
+         *                                  manufacturerData} is null while {@code
+         *                                  manufacturerDataMask} is not, or {@code
+         *                                  manufacturerData} and {@code manufacturerDataMask} have
+         *                                  different length.
+         */
+        public Builder setManufacturerData(
+                int manufacturerId,
+                @Nullable byte[] manufacturerData,
+                @Nullable byte[] manufacturerDataMask) {
+            if (manufacturerData != null && manufacturerId < 0) {
+                throw new IllegalArgumentException("invalid manufacture id");
+            }
+            if (manufacturerDataMask != null) {
+                if (manufacturerData == null) {
+                    throw new IllegalArgumentException(
+                            "manufacturerData is null while manufacturerDataMask is not null");
+                }
+                // Since the manufacturerDataMask is a bit mask for manufacturerData, the lengths
+                // of the two byte array need to be the same.
+                if (manufacturerData.length != manufacturerDataMask.length) {
+                    throw new IllegalArgumentException(
+                            "size mismatch for manufacturerData and manufacturerDataMask");
+                }
+            }
+            this.mManufacturerId = manufacturerId;
+            this.mManufacturerData = manufacturerData == null ? new byte[0] : manufacturerData;
+            this.mManufacturerDataMask = manufacturerDataMask;
+            return this;
+        }
+
+
+        /**
+         * Builds the filter.
+         *
+         * @throws IllegalArgumentException If the filter cannot be built.
+         */
+        public BleFilter build() {
+            return new BleFilter(
+                    mDeviceName,
+                    mDeviceAddress,
+                    mServiceUuid,
+                    mUuidMask,
+                    mServiceDataUuid,
+                    mServiceData,
+                    mServiceDataMask,
+                    mManufacturerId,
+                    mManufacturerData,
+                    mManufacturerDataMask);
+        }
+    }
+
+    /**
+     * Changes ble filter to os filter
+     */
+    public ScanFilter toOsFilter() {
+        ScanFilter.Builder osFilterBuilder = new ScanFilter.Builder();
+        if (!TextUtils.isEmpty(getDeviceAddress())) {
+            osFilterBuilder.setDeviceAddress(getDeviceAddress());
+        }
+        if (!TextUtils.isEmpty(getDeviceName())) {
+            osFilterBuilder.setDeviceName(getDeviceName());
+        }
+
+        byte[] manufacturerData = getManufacturerData();
+        if (getManufacturerId() != -1 && manufacturerData != null) {
+            byte[] manufacturerDataMask = getManufacturerDataMask();
+            if (manufacturerDataMask != null) {
+                osFilterBuilder.setManufacturerData(
+                        getManufacturerId(), manufacturerData, manufacturerDataMask);
+            } else {
+                osFilterBuilder.setManufacturerData(getManufacturerId(), manufacturerData);
+            }
+        }
+
+        ParcelUuid serviceDataUuid = getServiceDataUuid();
+        byte[] serviceData = getServiceData();
+        if (serviceDataUuid != null && serviceData != null) {
+            byte[] serviceDataMask = getServiceDataMask();
+            if (serviceDataMask != null) {
+                osFilterBuilder.setServiceData(serviceDataUuid, serviceData, serviceDataMask);
+            } else {
+                osFilterBuilder.setServiceData(serviceDataUuid, serviceData);
+            }
+        }
+
+        ParcelUuid serviceUuid = getServiceUuid();
+        if (serviceUuid != null) {
+            ParcelUuid serviceUuidMask = getServiceUuidMask();
+            if (serviceUuidMask != null) {
+                osFilterBuilder.setServiceUuid(serviceUuid, serviceUuidMask);
+            } else {
+                osFilterBuilder.setServiceUuid(serviceUuid);
+            }
+        }
+        return osFilterBuilder.build();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/BleRecord.java b/nearby/service/java/com/android/server/nearby/common/ble/BleRecord.java
new file mode 100644
index 0000000..103a27f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/BleRecord.java
@@ -0,0 +1,395 @@
+/*
+ * 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.common.ble;
+
+import android.os.ParcelUuid;
+import android.util.Log;
+import android.util.SparseArray;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.ble.util.StringUtils;
+
+import com.google.common.collect.ImmutableList;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Represents a BLE record from Bluetooth LE scan.
+ */
+public final class BleRecord {
+
+    // The following data type values are assigned by Bluetooth SIG.
+    // For more details refer to Bluetooth 4.1 specification, Volume 3, Part C, Section 18.
+    private static final int DATA_TYPE_FLAGS = 0x01;
+    private static final int DATA_TYPE_SERVICE_UUIDS_16_BIT_PARTIAL = 0x02;
+    private static final int DATA_TYPE_SERVICE_UUIDS_16_BIT_COMPLETE = 0x03;
+    private static final int DATA_TYPE_SERVICE_UUIDS_32_BIT_PARTIAL = 0x04;
+    private static final int DATA_TYPE_SERVICE_UUIDS_32_BIT_COMPLETE = 0x05;
+    private static final int DATA_TYPE_SERVICE_UUIDS_128_BIT_PARTIAL = 0x06;
+    private static final int DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE = 0x07;
+    private static final int DATA_TYPE_LOCAL_NAME_SHORT = 0x08;
+    private static final int DATA_TYPE_LOCAL_NAME_COMPLETE = 0x09;
+    private static final int DATA_TYPE_TX_POWER_LEVEL = 0x0A;
+    private static final int DATA_TYPE_SERVICE_DATA = 0x16;
+    private static final int DATA_TYPE_MANUFACTURER_SPECIFIC_DATA = 0xFF;
+
+    /** The base 128-bit UUID representation of a 16-bit UUID. */
+    private static final ParcelUuid BASE_UUID =
+            ParcelUuid.fromString("00000000-0000-1000-8000-00805F9B34FB");
+    /** Length of bytes for 16 bit UUID. */
+    private static final int UUID_BYTES_16_BIT = 2;
+    /** Length of bytes for 32 bit UUID. */
+    private static final int UUID_BYTES_32_BIT = 4;
+    /** Length of bytes for 128 bit UUID. */
+    private static final int UUID_BYTES_128_BIT = 16;
+
+    // Flags of the advertising data.
+    // -1 when the scan record is not valid.
+    private final int mAdvertiseFlags;
+
+    private final ImmutableList<ParcelUuid> mServiceUuids;
+
+    // null when the scan record is not valid.
+    @Nullable
+    private final SparseArray<byte[]> mManufacturerSpecificData;
+
+    // null when the scan record is not valid.
+    @Nullable
+    private final Map<ParcelUuid, byte[]> mServiceData;
+
+    // Transmission power level(in dB).
+    // Integer.MIN_VALUE when the scan record is not valid.
+    private final int mTxPowerLevel;
+
+    // Local name of the Bluetooth LE device.
+    // null when the scan record is not valid.
+    @Nullable
+    private final String mDeviceName;
+
+    // Raw bytes of scan record.
+    // Never null, whether valid or not.
+    private final byte[] mBytes;
+
+    // If the raw scan record byte[] cannot be parsed, all non-primitive args here other than the
+    // raw scan record byte[] and serviceUudis will be null. See parsefromBytes().
+    private BleRecord(
+            List<ParcelUuid> serviceUuids,
+            @Nullable SparseArray<byte[]> manufacturerData,
+            @Nullable Map<ParcelUuid, byte[]> serviceData,
+            int advertiseFlags,
+            int txPowerLevel,
+            @Nullable String deviceName,
+            byte[] bytes) {
+        this.mServiceUuids = ImmutableList.copyOf(serviceUuids);
+        mManufacturerSpecificData = manufacturerData;
+        this.mServiceData = serviceData;
+        this.mDeviceName = deviceName;
+        this.mAdvertiseFlags = advertiseFlags;
+        this.mTxPowerLevel = txPowerLevel;
+        this.mBytes = bytes;
+    }
+
+    /**
+     * Returns a list of service UUIDs within the advertisement that are used to identify the
+     * bluetooth GATT services.
+     */
+    public ImmutableList<ParcelUuid> getServiceUuids() {
+        return mServiceUuids;
+    }
+
+    /**
+     * Returns a sparse array of manufacturer identifier and its corresponding manufacturer specific
+     * data.
+     */
+    @Nullable
+    public SparseArray<byte[]> getManufacturerSpecificData() {
+        return mManufacturerSpecificData;
+    }
+
+    /**
+     * Returns the manufacturer specific data associated with the manufacturer id. Returns {@code
+     * null} if the {@code manufacturerId} is not found.
+     */
+    @Nullable
+    public byte[] getManufacturerSpecificData(int manufacturerId) {
+        if (mManufacturerSpecificData == null) {
+            return null;
+        }
+        return mManufacturerSpecificData.get(manufacturerId);
+    }
+
+    /** Returns a map of service UUID and its corresponding service data. */
+    @Nullable
+    public Map<ParcelUuid, byte[]> getServiceData() {
+        return mServiceData;
+    }
+
+    /**
+     * Returns the service data byte array associated with the {@code serviceUuid}. Returns {@code
+     * null} if the {@code serviceDataUuid} is not found.
+     */
+    @Nullable
+    public byte[] getServiceData(ParcelUuid serviceDataUuid) {
+        if (serviceDataUuid == null || mServiceData == null) {
+            return null;
+        }
+        return mServiceData.get(serviceDataUuid);
+    }
+
+    /**
+     * Returns the transmission power level of the packet in dBm. Returns {@link Integer#MIN_VALUE}
+     * if
+     * the field is not set. This value can be used to calculate the path loss of a received packet
+     * using the following equation:
+     *
+     * <p><code>pathloss = txPowerLevel - rssi</code>
+     */
+    public int getTxPowerLevel() {
+        return mTxPowerLevel;
+    }
+
+    /** Returns the local name of the BLE device. The is a UTF-8 encoded string. */
+    @Nullable
+    public String getDeviceName() {
+        return mDeviceName;
+    }
+
+    /** Returns raw bytes of scan record. */
+    public byte[] getBytes() {
+        return mBytes;
+    }
+
+    /**
+     * Parse scan record bytes to {@link BleRecord}.
+     *
+     * <p>The format is defined in Bluetooth 4.1 specification, Volume 3, Part C, Section 11 and 18.
+     *
+     * <p>All numerical multi-byte entities and values shall use little-endian <strong>byte</strong>
+     * order.
+     *
+     * @param scanRecord The scan record of Bluetooth LE advertisement and/or scan response.
+     */
+    public static BleRecord parseFromBytes(byte[] scanRecord) {
+        int currentPos = 0;
+        int advertiseFlag = -1;
+        List<ParcelUuid> serviceUuids = new ArrayList<>();
+        String localName = null;
+        int txPowerLevel = Integer.MIN_VALUE;
+
+        SparseArray<byte[]> manufacturerData = new SparseArray<>();
+        Map<ParcelUuid, byte[]> serviceData = new HashMap<>();
+
+        try {
+            while (currentPos < scanRecord.length) {
+                // length is unsigned int.
+                int length = scanRecord[currentPos++] & 0xFF;
+                if (length == 0) {
+                    break;
+                }
+                // Note the length includes the length of the field type itself.
+                int dataLength = length - 1;
+                // fieldType is unsigned int.
+                int fieldType = scanRecord[currentPos++] & 0xFF;
+                switch (fieldType) {
+                    case DATA_TYPE_FLAGS:
+                        advertiseFlag = scanRecord[currentPos] & 0xFF;
+                        break;
+                    case DATA_TYPE_SERVICE_UUIDS_16_BIT_PARTIAL:
+                    case DATA_TYPE_SERVICE_UUIDS_16_BIT_COMPLETE:
+                        parseServiceUuid(scanRecord, currentPos, dataLength, UUID_BYTES_16_BIT,
+                                serviceUuids);
+                        break;
+                    case DATA_TYPE_SERVICE_UUIDS_32_BIT_PARTIAL:
+                    case DATA_TYPE_SERVICE_UUIDS_32_BIT_COMPLETE:
+                        parseServiceUuid(scanRecord, currentPos, dataLength, UUID_BYTES_32_BIT,
+                                serviceUuids);
+                        break;
+                    case DATA_TYPE_SERVICE_UUIDS_128_BIT_PARTIAL:
+                    case DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE:
+                        parseServiceUuid(scanRecord, currentPos, dataLength, UUID_BYTES_128_BIT,
+                                serviceUuids);
+                        break;
+                    case DATA_TYPE_LOCAL_NAME_SHORT:
+                    case DATA_TYPE_LOCAL_NAME_COMPLETE:
+                        localName = new String(extractBytes(scanRecord, currentPos, dataLength));
+                        break;
+                    case DATA_TYPE_TX_POWER_LEVEL:
+                        txPowerLevel = scanRecord[currentPos];
+                        break;
+                    case DATA_TYPE_SERVICE_DATA:
+                        // The first two bytes of the service data are service data UUID in little
+                        // endian. The rest bytes are service data.
+                        int serviceUuidLength = UUID_BYTES_16_BIT;
+                        byte[] serviceDataUuidBytes = extractBytes(scanRecord, currentPos,
+                                serviceUuidLength);
+                        ParcelUuid serviceDataUuid = parseUuidFrom(serviceDataUuidBytes);
+                        byte[] serviceDataArray =
+                                extractBytes(
+                                        scanRecord, currentPos + serviceUuidLength,
+                                        dataLength - serviceUuidLength);
+                        serviceData.put(serviceDataUuid, serviceDataArray);
+                        break;
+                    case DATA_TYPE_MANUFACTURER_SPECIFIC_DATA:
+                        // The first two bytes of the manufacturer specific data are
+                        // manufacturer ids in little endian.
+                        int manufacturerId =
+                                ((scanRecord[currentPos + 1] & 0xFF) << 8) + (scanRecord[currentPos]
+                                        & 0xFF);
+                        byte[] manufacturerDataBytes = extractBytes(scanRecord, currentPos + 2,
+                                dataLength - 2);
+                        manufacturerData.put(manufacturerId, manufacturerDataBytes);
+                        break;
+                    default:
+                        // Just ignore, we don't handle such data type.
+                        break;
+                }
+                currentPos += dataLength;
+            }
+
+            return new BleRecord(
+                    serviceUuids,
+                    manufacturerData,
+                    serviceData,
+                    advertiseFlag,
+                    txPowerLevel,
+                    localName,
+                    scanRecord);
+        } catch (Exception e) {
+            Log.w("BleRecord", "Unable to parse scan record: " + Arrays.toString(scanRecord), e);
+            // As the record is invalid, ignore all the parsed results for this packet
+            // and return an empty record with raw scanRecord bytes in results
+            // check at the top of this method does? Maybe we expect callers to use the
+            // scanRecord part in
+            // some fallback. But if that's the reason, it would seem we still can return null.
+            // They still
+            // have the raw scanRecord in hand, 'cause they passed it to us. It seems too easy for a
+            // caller to misuse this "empty" BleRecord (as in b/22693067).
+            return new BleRecord(ImmutableList.of(), null, null, -1, Integer.MIN_VALUE, null,
+                    scanRecord);
+        }
+    }
+
+    // Parse service UUIDs.
+    private static int parseServiceUuid(
+            byte[] scanRecord,
+            int currentPos,
+            int dataLength,
+            int uuidLength,
+            List<ParcelUuid> serviceUuids) {
+        while (dataLength > 0) {
+            byte[] uuidBytes = extractBytes(scanRecord, currentPos, uuidLength);
+            serviceUuids.add(parseUuidFrom(uuidBytes));
+            dataLength -= uuidLength;
+            currentPos += uuidLength;
+        }
+        return currentPos;
+    }
+
+    // Helper method to extract bytes from byte array.
+    private static byte[] extractBytes(byte[] scanRecord, int start, int length) {
+        byte[] bytes = new byte[length];
+        System.arraycopy(scanRecord, start, bytes, 0, length);
+        return bytes;
+    }
+
+    @Override
+    public String toString() {
+        return "BleRecord [advertiseFlags="
+                + mAdvertiseFlags
+                + ", serviceUuids="
+                + mServiceUuids
+                + ", manufacturerSpecificData="
+                + StringUtils.toString(mManufacturerSpecificData)
+                + ", serviceData="
+                + StringUtils.toString(mServiceData)
+                + ", txPowerLevel="
+                + mTxPowerLevel
+                + ", deviceName="
+                + mDeviceName
+                + "]";
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj == this) {
+            return true;
+        }
+        if (!(obj instanceof BleRecord)) {
+            return false;
+        }
+        BleRecord record = (BleRecord) obj;
+        // BleRecord objects are built from bytes, so we only need that field.
+        return Arrays.equals(mBytes, record.mBytes);
+    }
+
+    @Override
+    public int hashCode() {
+        // BleRecord objects are built from bytes, so we only need that field.
+        return Arrays.hashCode(mBytes);
+    }
+
+    /**
+     * Parse UUID from bytes. The {@code uuidBytes} can represent a 16-bit, 32-bit or 128-bit UUID,
+     * but the returned UUID is always in 128-bit format. Note UUID is little endian in Bluetooth.
+     *
+     * @param uuidBytes Byte representation of uuid.
+     * @return {@link ParcelUuid} parsed from bytes.
+     * @throws IllegalArgumentException If the {@code uuidBytes} cannot be parsed.
+     */
+    private static ParcelUuid parseUuidFrom(byte[] uuidBytes) {
+        if (uuidBytes == null) {
+            throw new IllegalArgumentException("uuidBytes cannot be null");
+        }
+        int length = uuidBytes.length;
+        if (length != UUID_BYTES_16_BIT
+                && length != UUID_BYTES_32_BIT
+                && length != UUID_BYTES_128_BIT) {
+            throw new IllegalArgumentException("uuidBytes length invalid - " + length);
+        }
+        // Construct a 128 bit UUID.
+        if (length == UUID_BYTES_128_BIT) {
+            ByteBuffer buf = ByteBuffer.wrap(uuidBytes).order(ByteOrder.LITTLE_ENDIAN);
+            long msb = buf.getLong(8);
+            long lsb = buf.getLong(0);
+            return new ParcelUuid(new UUID(msb, lsb));
+        }
+        // For 16 bit and 32 bit UUID we need to convert them to 128 bit value.
+        // 128_bit_value = uuid * 2^96 + BASE_UUID
+        long shortUuid;
+        if (length == UUID_BYTES_16_BIT) {
+            shortUuid = uuidBytes[0] & 0xFF;
+            shortUuid += (uuidBytes[1] & 0xFF) << 8;
+        } else {
+            shortUuid = uuidBytes[0] & 0xFF;
+            shortUuid += (uuidBytes[1] & 0xFF) << 8;
+            shortUuid += (uuidBytes[2] & 0xFF) << 16;
+            shortUuid += (uuidBytes[3] & 0xFF) << 24;
+        }
+        long msb = BASE_UUID.getUuid().getMostSignificantBits() + (shortUuid << 32);
+        long lsb = BASE_UUID.getUuid().getLeastSignificantBits();
+        return new ParcelUuid(new UUID(msb, lsb));
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/BleSighting.java b/nearby/service/java/com/android/server/nearby/common/ble/BleSighting.java
new file mode 100644
index 0000000..71ec10c
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/BleSighting.java
@@ -0,0 +1,215 @@
+/*
+ * 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.common.ble;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.os.Build.VERSION_CODES;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A sighting of a BLE device found in a Bluetooth LE scan.
+ */
+
+public class BleSighting implements Parcelable {
+
+    public static final Parcelable.Creator<BleSighting> CREATOR = new Creator<BleSighting>() {
+        @Override
+        public BleSighting createFromParcel(Parcel source) {
+            BleSighting nBleSighting = new BleSighting(source.readParcelable(null),
+                    source.marshall(), source.readInt(), source.readLong());
+            return null;
+        }
+
+        @Override
+        public BleSighting[] newArray(int size) {
+            return new BleSighting[size];
+        }
+    };
+
+    // Max and min rssi value which is from {@link android.bluetooth.le.ScanResult#getRssi()}.
+    @VisibleForTesting
+    public static final int MAX_RSSI_VALUE = 126;
+    @VisibleForTesting
+    public static final int MIN_RSSI_VALUE = -127;
+
+    /** Remote bluetooth device. */
+    private final BluetoothDevice mDevice;
+
+    /**
+     * BLE record, including advertising data and response data. BleRecord is not parcelable, so
+     * this
+     * is created from bleRecordBytes.
+     */
+    private final BleRecord mBleRecord;
+
+    /** The bytes of a BLE record. */
+    private final byte[] mBleRecordBytes;
+
+    /** Received signal strength. */
+    private final int mRssi;
+
+    /** Nanos timestamp when the ble device was observed (epoch time). */
+    private final long mTimestampEpochNanos;
+
+    /**
+     * Constructor of a BLE sighting.
+     *
+     * @param device              Remote bluetooth device that is found.
+     * @param bleRecordBytes      The bytes that will create a BleRecord.
+     * @param rssi                Received signal strength.
+     * @param timestampEpochNanos Nanos timestamp when the BLE device was observed (epoch time).
+     */
+    public BleSighting(BluetoothDevice device, byte[] bleRecordBytes, int rssi,
+            long timestampEpochNanos) {
+        this.mDevice = device;
+        this.mBleRecordBytes = bleRecordBytes;
+        this.mRssi = rssi;
+        this.mTimestampEpochNanos = timestampEpochNanos;
+        mBleRecord = BleRecord.parseFromBytes(bleRecordBytes);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    /** Returns the remote bluetooth device identified by the bluetooth device address. */
+    public BluetoothDevice getDevice() {
+        return mDevice;
+    }
+
+    /** Returns the BLE record, which is a combination of advertisement and scan response. */
+    public BleRecord getBleRecord() {
+        return mBleRecord;
+    }
+
+    /** Returns the bytes of the BLE record. */
+    public byte[] getBleRecordBytes() {
+        return mBleRecordBytes;
+    }
+
+    /** Returns the received signal strength in dBm. The valid range is [-127, 127]. */
+    public int getRssi() {
+        return mRssi;
+    }
+
+    /**
+     * Returns the received signal strength normalized with the offset specific to the given device.
+     * 3 is the rssi offset to calculate fast init distance.
+     * <p>This method utilized the rssi offset maintained by Nearby Sharing.
+     *
+     * @return normalized rssi which is between [-127, 126] according to {@link
+     * android.bluetooth.le.ScanResult#getRssi()}.
+     */
+    public int getNormalizedRSSI() {
+        int adjustedRssi = mRssi + 3;
+        if (adjustedRssi < MIN_RSSI_VALUE) {
+            return MIN_RSSI_VALUE;
+        } else if (adjustedRssi > MAX_RSSI_VALUE) {
+            return MAX_RSSI_VALUE;
+        } else {
+            return adjustedRssi;
+        }
+    }
+
+    /** Returns timestamp in epoch time when the scan record was observed. */
+    public long getTimestampNanos() {
+        return mTimestampEpochNanos;
+    }
+
+    /** Returns timestamp in epoch time when the scan record was observed, in millis. */
+    public long getTimestampMillis() {
+        return TimeUnit.NANOSECONDS.toMillis(mTimestampEpochNanos);
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeParcelable(mDevice, flags);
+        dest.writeByteArray(mBleRecordBytes);
+        dest.writeInt(mRssi);
+        dest.writeLong(mTimestampEpochNanos);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mDevice, mRssi, mTimestampEpochNanos, Arrays.hashCode(mBleRecordBytes));
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof BleSighting)) {
+            return false;
+        }
+        BleSighting other = (BleSighting) obj;
+        return Objects.equals(mDevice, other.mDevice)
+                && mRssi == other.mRssi
+                && Arrays.equals(mBleRecordBytes, other.mBleRecordBytes)
+                && mTimestampEpochNanos == other.mTimestampEpochNanos;
+    }
+
+    @Override
+    public String toString() {
+        return "BleSighting{"
+                + "device="
+                + mDevice
+                + ", bleRecord="
+                + mBleRecord
+                + ", rssi="
+                + mRssi
+                + ", timestampNanos="
+                + mTimestampEpochNanos
+                + "}";
+    }
+
+    /** Creates {@link BleSighting} using the {@link ScanResult}. */
+    @RequiresApi(api = VERSION_CODES.LOLLIPOP)
+    @Nullable
+    public static BleSighting createFromOsScanResult(ScanResult osResult) {
+        ScanRecord osScanRecord = osResult.getScanRecord();
+        if (osScanRecord == null) {
+            return null;
+        }
+
+        return new BleSighting(
+                osResult.getDevice(),
+                osScanRecord.getBytes(),
+                osResult.getRssi(),
+                // The timestamp from ScanResult is 'nanos since boot', Beacon lib will change it
+                // as 'nanos
+                // since epoch', but Nearby never reference this field, just pass it as 'nanos
+                // since boot'.
+                // ref to beacon/scan/impl/LBluetoothLeScannerCompat.fromOs for beacon design
+                // about how to
+                // convert nanos since boot to epoch.
+                osResult.getTimestampNanos());
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/decode/BeaconDecoder.java b/nearby/service/java/com/android/server/nearby/common/ble/decode/BeaconDecoder.java
new file mode 100644
index 0000000..9e795ac
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/decode/BeaconDecoder.java
@@ -0,0 +1,106 @@
+/*
+ * 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.common.ble.decode;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.ble.BleRecord;
+
+/**
+ * This class encapsulates the logic specific to each manufacturer for parsing formats for beacons,
+ * and presents a common API to access important ADV/EIR packet fields such as:
+ *
+ * <ul>
+ *   <li><b>UUID (universally unique identifier)</b>, a value uniquely identifying a group of one or
+ *       more beacons as belonging to an organization or of a certain type, up to 128 bits.
+ *   <li><b>Instance</b> a 32-bit unsigned integer that can be used to group related beacons that
+ *       have the same UUID.
+ *   <li>the mathematics of <b>TX signal strength</b>, used for proximity calculations.
+ * </ul>
+ *
+ * ...and others.
+ *
+ * @see <a href="http://go/ble-glossary">BLE Glossary</a>
+ * @see <a href="https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=245130">Bluetooth
+ * Data Types Specification</a>
+ */
+public abstract class BeaconDecoder {
+    /**
+     * Returns true if the bleRecord corresponds to a beacon format that contains sufficient
+     * information to construct a BeaconId and contains the Tx power.
+     */
+    public boolean supportsBeaconIdAndTxPower(@SuppressWarnings("unused") BleRecord bleRecord) {
+        return true;
+    }
+
+    /**
+     * Returns true if this decoder supports returning TxPower via {@link
+     * #getCalibratedBeaconTxPower(BleRecord)}.
+     */
+    public boolean supportsTxPower() {
+        return true;
+    }
+
+    /**
+     * Reads the calibrated transmitted power at 1 meter of the beacon in dBm. This value is
+     * contained
+     * in the scan record, as set by the transmitting beacon. Suitable for use in computing path
+     * loss,
+     * distance, and related derived values.
+     *
+     * @param bleRecord the parsed payload contained in the beacon packet
+     * @return integer value of the calibrated Tx power in dBm or null if the bleRecord doesn't
+     * contain sufficient information to calculate the Tx power.
+     */
+    @Nullable
+    public abstract Integer getCalibratedBeaconTxPower(BleRecord bleRecord);
+
+    /**
+     * Extract telemetry information from the beacon. Byte 0 of the returned telemetry block should
+     * encode the telemetry format.
+     *
+     * @return telemetry block for this beacon, or null if no telemetry data is found in the scan
+     * record.
+     */
+    @Nullable
+    public byte[] getTelemetry(@SuppressWarnings("unused") BleRecord bleRecord) {
+        return null;
+    }
+
+    /** Returns the appropriate type for this scan record. */
+    public abstract int getBeaconIdType();
+
+    /**
+     * Returns an array of bytes which uniquely identify this beacon, for beacons from any of the
+     * supported beacon types. This unique identifier is the indexing key for various internal
+     * services. Returns null if the bleRecord doesn't contain sufficient information to construct
+     * the
+     * ID.
+     */
+    @Nullable
+    public abstract byte[] getBeaconIdBytes(BleRecord bleRecord);
+
+    /**
+     * Returns the URL of the beacon. Returns null if the bleRecord doesn't contain a URL or
+     * contains
+     * a malformed URL.
+     */
+    @Nullable
+    public String getUrl(BleRecord bleRecord) {
+        return null;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/decode/FastPairDecoder.java b/nearby/service/java/com/android/server/nearby/common/ble/decode/FastPairDecoder.java
new file mode 100644
index 0000000..c1ff9fd
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/decode/FastPairDecoder.java
@@ -0,0 +1,297 @@
+/*
+ * 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.common.ble.decode;
+
+import android.bluetooth.le.ScanRecord;
+import android.os.ParcelUuid;
+import android.util.Log;
+import android.util.SparseArray;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.ble.BleFilter;
+import com.android.server.nearby.common.ble.BleRecord;
+
+import java.util.Arrays;
+
+/**
+ * Parses Fast Pair information out of {@link BleRecord}s.
+ *
+ * <p>There are 2 different packet formats that are supported, which is used can be determined by
+ * packet length:
+ *
+ * <p>For 3-byte packets, the full packet is the model ID.
+ *
+ * <p>For all other packets, the first byte is the header, followed by the model ID, followed by
+ * zero or more extra fields. Each field has its own header byte followed by the field value. The
+ * packet header is formatted as 0bVVVLLLLR (V = version, L = model ID length, R = reserved) and
+ * each extra field header is 0bLLLLTTTT (L = field length, T = field type).
+ *
+ * @see <a href="http://go/fast-pair-2-service-data">go/fast-pair-2-service-data</a>
+ */
+public class FastPairDecoder extends BeaconDecoder {
+
+    private static final int FIELD_TYPE_BLOOM_FILTER = 0;
+    private static final int FIELD_TYPE_BLOOM_FILTER_SALT = 1;
+    private static final int FIELD_TYPE_BLOOM_FILTER_NO_NOTIFICATION = 2;
+    private static final int FIELD_TYPE_BATTERY = 3;
+    private static final int FIELD_TYPE_BATTERY_NO_NOTIFICATION = 4;
+    public static final int FIELD_TYPE_CONNECTION_STATE = 5;
+    private static final int FIELD_TYPE_RANDOM_RESOLVABLE_DATA = 6;
+
+    /** FE2C is the 16-bit Service UUID. The rest is the base UUID. See BluetoothUuid (hidden). */
+    private static final ParcelUuid FAST_PAIR_SERVICE_PARCEL_UUID =
+            ParcelUuid.fromString("0000FE2C-0000-1000-8000-00805F9B34FB");
+
+    /** The filter you use to scan for Fast Pair BLE advertisements. */
+    public static final BleFilter FILTER =
+            new BleFilter.Builder().setServiceData(FAST_PAIR_SERVICE_PARCEL_UUID,
+                    new byte[0]).build();
+
+    // NOTE: Ensure that all bitmasks are always ints, not bytes so that bitshifting works correctly
+    // without needing worry about signing errors.
+    private static final int HEADER_VERSION_BITMASK = 0b11100000;
+    private static final int HEADER_LENGTH_BITMASK = 0b00011110;
+    private static final int HEADER_VERSION_OFFSET = 5;
+    private static final int HEADER_LENGTH_OFFSET = 1;
+
+    private static final int EXTRA_FIELD_LENGTH_BITMASK = 0b11110000;
+    private static final int EXTRA_FIELD_TYPE_BITMASK = 0b00001111;
+    private static final int EXTRA_FIELD_LENGTH_OFFSET = 4;
+    private static final int EXTRA_FIELD_TYPE_OFFSET = 0;
+
+    private static final int MIN_ID_LENGTH = 3;
+    private static final int MAX_ID_LENGTH = 14;
+    private static final int HEADER_INDEX = 0;
+    private static final int HEADER_LENGTH = 1;
+    private static final int FIELD_HEADER_LENGTH = 1;
+
+    // Not using java.util.IllegalFormatException because it is unchecked.
+    private static class IllegalFormatException extends Exception {
+        private IllegalFormatException(String message) {
+            super(message);
+        }
+    }
+
+    @Nullable
+    @Override
+    public Integer getCalibratedBeaconTxPower(BleRecord bleRecord) {
+        return null;
+    }
+
+    // TODO(b/205320613) create beacon type
+    @Override
+    public int getBeaconIdType() {
+        return 1;
+    }
+
+    /** Returns the Model ID from our service data, if present. */
+    @Nullable
+    @Override
+    public byte[] getBeaconIdBytes(BleRecord bleRecord) {
+        return getModelId(bleRecord.getServiceData(FAST_PAIR_SERVICE_PARCEL_UUID));
+    }
+
+    /** Returns the Model ID from our service data, if present. */
+    @Nullable
+    public static byte[] getModelId(@Nullable byte[] serviceData) {
+        if (serviceData == null) {
+            return null;
+        }
+
+        if (serviceData.length >= MIN_ID_LENGTH) {
+            if (serviceData.length == MIN_ID_LENGTH) {
+                // If the length == 3, all bytes are the ID. See flag docs for more about
+                // endianness.
+                return serviceData;
+            } else {
+                // Otherwise, the first byte is a header which contains the length of the
+                // big-endian model
+                // ID that follows. The model ID will be trimmed if it contains leading zeros.
+                int idIndex = 1;
+                int end = idIndex + getIdLength(serviceData);
+                while (serviceData[idIndex] == 0 && end - idIndex > MIN_ID_LENGTH) {
+                    idIndex++;
+                }
+                return Arrays.copyOfRange(serviceData, idIndex, end);
+            }
+        }
+        return null;
+    }
+
+    /** Gets the FastPair service data array if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getServiceDataArray(BleRecord bleRecord) {
+        return bleRecord.getServiceData(FAST_PAIR_SERVICE_PARCEL_UUID);
+    }
+
+    /** Gets the FastPair service data array if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getServiceDataArray(ScanRecord scanRecord) {
+        return scanRecord.getServiceData(FAST_PAIR_SERVICE_PARCEL_UUID);
+    }
+
+    /** Gets the bloom filter from the extra fields if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getBloomFilter(@Nullable byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BLOOM_FILTER);
+    }
+
+    /** Gets the bloom filter salt from the extra fields if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getBloomFilterSalt(byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BLOOM_FILTER_SALT);
+    }
+
+    /**
+     * Gets the suppress notification with bloom filter from the extra fields if available,
+     * otherwise
+     * returns null.
+     */
+    @Nullable
+    public static byte[] getBloomFilterNoNotification(@Nullable byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BLOOM_FILTER_NO_NOTIFICATION);
+    }
+
+    /** Gets the battery level from extra fields if available, otherwise return null. */
+    @Nullable
+    public static byte[] getBatteryLevel(byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BATTERY);
+    }
+
+    /**
+     * Gets the suppress notification with battery level from extra fields if available, otherwise
+     * return null.
+     */
+    @Nullable
+    public static byte[] getBatteryLevelNoNotification(byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BATTERY_NO_NOTIFICATION);
+    }
+
+    /**
+     * Gets the random resolvable data from extra fields if available, otherwise
+     * return null.
+     */
+    @Nullable
+    public static byte[] getRandomResolvableData(byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_RANDOM_RESOLVABLE_DATA);
+    }
+
+    @Nullable
+    private static byte[] getExtraField(@Nullable byte[] serviceData, int fieldId) {
+        if (serviceData == null || serviceData.length < HEADER_INDEX + HEADER_LENGTH) {
+            return null;
+        }
+        try {
+            return getExtraFields(serviceData).get(fieldId);
+        } catch (IllegalFormatException e) {
+            Log.v("FastPairDecode", "Extra fields incorrectly formatted.");
+            return null;
+        }
+    }
+
+    /** Gets extra field data at the end of the packet, defined by the extra field header. */
+    private static SparseArray<byte[]> getExtraFields(byte[] serviceData)
+            throws IllegalFormatException {
+        SparseArray<byte[]> extraFields = new SparseArray<>();
+        if (getVersion(serviceData) != 0) {
+            return extraFields;
+        }
+        int headerIndex = getFirstExtraFieldHeaderIndex(serviceData);
+        while (headerIndex < serviceData.length) {
+            int length = getExtraFieldLength(serviceData, headerIndex);
+            int index = headerIndex + FIELD_HEADER_LENGTH;
+            int type = getExtraFieldType(serviceData, headerIndex);
+            int end = index + length;
+            if (extraFields.get(type) == null) {
+                if (end <= serviceData.length) {
+                    extraFields.put(type, Arrays.copyOfRange(serviceData, index, end));
+                } else {
+                    throw new IllegalFormatException(
+                            "Invalid length, " + end + " is longer than service data size "
+                                    + serviceData.length);
+                }
+            }
+            headerIndex = end;
+        }
+        return extraFields;
+    }
+
+    /** Checks whether or not a valid ID is included in the service data packet. */
+    public static boolean hasBeaconIdBytes(BleRecord bleRecord) {
+        byte[] serviceData = bleRecord.getServiceData(FAST_PAIR_SERVICE_PARCEL_UUID);
+        return checkModelId(serviceData);
+    }
+
+    /** Check whether byte array is FastPair model id or not. */
+    public static boolean checkModelId(@Nullable byte[] scanResult) {
+        return scanResult != null
+                // The 3-byte format has no header byte (all bytes are the ID).
+                && (scanResult.length == MIN_ID_LENGTH
+                // Header byte exists. We support only format version 0. (A different version
+                // indicates
+                // a breaking change in the format.)
+                || (scanResult.length > MIN_ID_LENGTH
+                && getVersion(scanResult) == 0
+                && isIdLengthValid(scanResult)));
+    }
+
+    /** Checks whether or not bloom filter is included in the service data packet. */
+    public static boolean hasBloomFilter(BleRecord bleRecord) {
+        return (getBloomFilter(getServiceDataArray(bleRecord)) != null
+                || getBloomFilterNoNotification(getServiceDataArray(bleRecord)) != null);
+    }
+
+    /** Checks whether or not bloom filter is included in the service data packet. */
+    public static boolean hasBloomFilter(ScanRecord scanRecord) {
+        return (getBloomFilter(getServiceDataArray(scanRecord)) != null
+                || getBloomFilterNoNotification(getServiceDataArray(scanRecord)) != null);
+    }
+
+    private static int getVersion(byte[] serviceData) {
+        return serviceData.length == MIN_ID_LENGTH
+                ? 0
+                : (serviceData[HEADER_INDEX] & HEADER_VERSION_BITMASK) >> HEADER_VERSION_OFFSET;
+    }
+
+    private static int getIdLength(byte[] serviceData) {
+        return serviceData.length == MIN_ID_LENGTH
+                ? MIN_ID_LENGTH
+                : (serviceData[HEADER_INDEX] & HEADER_LENGTH_BITMASK) >> HEADER_LENGTH_OFFSET;
+    }
+
+    private static int getFirstExtraFieldHeaderIndex(byte[] serviceData) {
+        return HEADER_INDEX + HEADER_LENGTH + getIdLength(serviceData);
+    }
+
+    private static int getExtraFieldLength(byte[] serviceData, int extraFieldIndex) {
+        return (serviceData[extraFieldIndex] & EXTRA_FIELD_LENGTH_BITMASK)
+                >> EXTRA_FIELD_LENGTH_OFFSET;
+    }
+
+    private static int getExtraFieldType(byte[] serviceData, int extraFieldIndex) {
+        return (serviceData[extraFieldIndex] & EXTRA_FIELD_TYPE_BITMASK) >> EXTRA_FIELD_TYPE_OFFSET;
+    }
+
+    private static boolean isIdLengthValid(byte[] serviceData) {
+        int idLength = getIdLength(serviceData);
+        return MIN_ID_LENGTH <= idLength
+                && idLength <= MAX_ID_LENGTH
+                && idLength + HEADER_LENGTH <= serviceData.length;
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/testing/FastPairTestData.java b/nearby/service/java/com/android/server/nearby/common/ble/testing/FastPairTestData.java
new file mode 100644
index 0000000..f27899f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/testing/FastPairTestData.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.common.ble.testing;
+
+import com.android.server.nearby.util.ArrayUtils;
+import com.android.server.nearby.util.Hex;
+
+/**
+ * Test class to provide example to unit test.
+ */
+public class FastPairTestData {
+    private static final byte[] FAST_PAIR_RECORD_BIG_ENDIAN =
+            Hex.stringToBytes("02011E020AF006162CFEAABBCC");
+
+    /**
+     * A Fast Pair frame, Note: The service UUID is FE2C, but in the
+     * packet it's 2CFE, since the core Bluetooth data types are little-endian.
+     *
+     * <p>However, the model ID is big-endian (multi-byte values in our spec are now big-endian, aka
+     * network byte order).
+     *
+     * @see {http://go/fast-pair-service-data}
+     */
+    public static byte[] getFastPairRecord() {
+        return FAST_PAIR_RECORD_BIG_ENDIAN;
+    }
+
+    /** A Fast Pair frame, with a shared account key. */
+    public static final byte[] FAST_PAIR_SHARED_ACCOUNT_KEY_RECORD =
+            Hex.stringToBytes("02011E020AF00C162CFE007011223344556677");
+
+    /** Model ID in {@link #getFastPairRecord()}. */
+    public static final byte[] FAST_PAIR_MODEL_ID = Hex.stringToBytes("AABBCC");
+
+    /** @see #getFastPairRecord() */
+    public static byte[] newFastPairRecord(byte header, byte[] modelId) {
+        return newFastPairRecord(
+                modelId.length == 3 ? modelId : ArrayUtils.concatByteArrays(new byte[] {header},
+                        modelId));
+    }
+
+    /** @see #getFastPairRecord() */
+    public static byte[] newFastPairRecord(byte[] serviceData) {
+        int length = /* length of type and service UUID = */ 3 + serviceData.length;
+        return Hex.stringToBytes(
+                String.format("02011E020AF0%02X162CFE%s", length,
+                        Hex.bytesToStringUppercase(serviceData)));
+    }
+
+    // This is an example of advertising data with AD types
+    public static byte[] adv_1 = {
+            0x02, // Length of this Data
+            0x01, // <<Flags>>
+            0x01, // LE Limited Discoverable Mode
+            0x0A, // Length of this Data
+            0x09, // <<Complete local name>>
+            'P', 'e', 'd', 'o', 'm', 'e', 't', 'e', 'r'
+    };
+
+    // This is an example of advertising data with positive TX Power
+    // Level.
+    public static byte[] adv_2 = {
+            0x02, // Length of this Data
+            0x0a, // <<TX Power Level>>
+            127 // Level = 127
+    };
+
+    // Example data including a service data block
+    public static byte[] sd1 = {
+            0x02, // Length of this Data
+            0x01, // <<Flags>>
+            0x04, // BR/EDR Not Supported.
+            0x03, // Length of this Data
+            0x02, // <<Incomplete List of 16-bit Service UUIDs>>
+            0x04,
+            0x18, // TX Power Service UUID
+            0x1e, // Length of this Data
+            (byte) 0x16, // <<Service Specific Data>>
+            // Service UUID
+            (byte) 0xe0,
+            0x00,
+            // gBeacon Header
+            0x15,
+            // Running time ENCRYPT
+            (byte) 0xd2,
+            0x77,
+            0x01,
+            0x00,
+            // Scan Freq ENCRYPT
+            0x32,
+            0x05,
+            // Time in slow mode
+            0x00,
+            0x00,
+            // Time in fast mode
+            0x7f,
+            0x17,
+            // Subset of UID
+            0x56,
+            0x00,
+            // ID Mask
+            (byte) 0xd4,
+            0x7c,
+            0x18,
+            // RFU (reserved)
+            0x00,
+            // GUID = decimal 1297482358
+            0x76,
+            0x02,
+            0x56,
+            0x4d,
+            0x00,
+            // Ranging Payload Header
+            0x24,
+            // MAC of scanning address
+            (byte) 0xa4,
+            (byte) 0xbb,
+            // NORM RX RSSI -67dBm
+            (byte) 0xb0,
+            // NORM TX POWER -77dBm, so actual TX POWER = -36dBm
+            (byte) 0xb3,
+            // Note based on the values aboves PATH LOSS = (-36) - (-67) = 31dBm
+            // Below zero padding added to test it is handled correctly
+            0x00
+    };
+
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/util/RangingUtils.java b/nearby/service/java/com/android/server/nearby/common/ble/util/RangingUtils.java
new file mode 100644
index 0000000..321e125
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/util/RangingUtils.java
@@ -0,0 +1,163 @@
+/*
+ * 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.common.ble.util;
+
+
+/**
+ * Ranging utilities embody the physics of converting RF path loss to distance. The free space path
+ * loss is proportional to the square of the distance from transmitter to receiver, and to the
+ * square of the frequency of the propagation signal.
+ */
+public final class RangingUtils {
+    /*
+     * Key to variable names used in this class (viz. Physics):
+     *
+     * c   = speed of light (2.9979 x 10^8 m/s);
+     * f   = frequency (Bluetooth center frequency is 2.44175GHz = 2.44175x10^9 Hz);
+     * l   = wavelength (meters);
+     * d   = distance (from transmitter to receiver in meters);
+     * dB  = decibels
+     * dBm = decibel milliwatts
+     *
+     *
+     * Free-space path loss (FSPL) is proportional to the square of the distance between the
+     * transmitter and the receiver, and also proportional to the square of the frequency of the
+     * radio signal.
+     *
+     * FSPL      = (4 * pi * d / l)^2 = (4 * pi * d * f / c)^2
+     *
+     * FSPL (dB) = 10*log10((4 * pi * d * f / c)^2)
+     *           = 20*log10(4 * pi * d * f / c)
+     *           = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c)
+     *
+     * Calculating constants:
+     *
+     * FSPL_FREQ        = 20*log10(f)
+     *                  = 20*log10(2.44175 * 10^9)
+     *                  = 187.75
+     *
+     * FSPL_LIGHT       = 20*log10(4*pi/c)
+     *                  = 20*log10(4*pi/(2.9979*10^8))
+     *                  = -147.55
+     *
+     * FSPL_DISTANCE_1M = 20*log10(1m)
+     *                  = 0
+     *
+     * PATH_LOSS_AT_1M  = FSPL_DISTANCE_1M + FSPL_FREQ + FSPL_LIGHT
+     *                  = 0                + 187.75    + (-147.55)
+     *                  = 40.20 [round to 41]
+     *
+     * Note that PATH_LOSS_AT_1M is rounded to 41 instead to the more natural 40. The first version
+     * of this file had a typo that caused the value to be close to 41; when this was discovered,
+     * the value 41 was already used in many places, and it was more important to be consistent
+     * rather than exact.
+     *
+     * Given this we can work out a formula for distance from a given RSSI (received signal strength
+     * indicator) and a given value for the expected strength at one meter from the beacon (aka
+     * calibrated transmission power). Both values are in dBm.
+     *
+     * FSPL = 20*log10(d) + PATH_LOSS_AT_1M = full_power - RSSI
+     *        20*log10(d) + PATH_LOSS_AT_1M = power_at_1m + PATH_LOSS_AT_1M - RSSI
+     *        20*log10(d)                   = power_at_1m - RSSI
+     *           log10(d)                   = (power_at_1m - RSSI) / 20
+     *                 d                    = 10 ^ ((power_at_1m - RSSI) / 20)
+     *
+     * Note: because of how logarithms work, units get a bit funny. If you take a two values x and y
+     * whose units are dBm, the value of x - y has units of dB, not dBm. Similarly, if x is dBm and
+     * y is in dB, then x - y will be in dBm.
+     */
+
+    /* (dBm) PATH_LOSS at 1m for isotropic antenna transmitting BLE */
+    public static final int PATH_LOSS_AT_1M = 41;
+
+    /** Different region categories, based on distance range. */
+    public static final class Region {
+
+        public static final int UNKNOWN = -1;
+        public static final int NEAR = 0;
+        public static final int MID = 1;
+        public static final int FAR = 2;
+
+        private Region() {}
+    }
+
+    // Cutoff distances between different regions.
+    public static final double NEAR_TO_MID_METERS = 0.5;
+    public static final double MID_TO_FAR_METERS = 2.0;
+
+    public static final int DEFAULT_CALIBRATED_TX_POWER = -77;
+
+    private RangingUtils() {}
+
+    /**
+     * Convert RSSI to path loss using the free space path loss equation. See <a
+     * href="http://en.wikipedia.org/wiki/Free-space_path_loss">Free-space_path_loss</a>
+     *
+     * @param rssi Received Signal Strength Indication (RSSI) in dBm
+     * @param calibratedTxPower the calibrated power of the transmitter (dBm) at 1 meter
+     * @return The calculated path loss.
+     */
+    public static int pathLossFromRssi(int rssi, int calibratedTxPower) {
+        return calibratedTxPower + PATH_LOSS_AT_1M - rssi;
+    }
+
+    /**
+     * Convert RSSI to distance using the free space path loss equation. See <a
+     * href="http://en.wikipedia.org/wiki/Free-space_path_loss">Free-space_path_loss</a>
+     *
+     * @param rssi Received Signal Strength Indication (RSSI) in dBm
+     * @param calibratedTxPower the calibrated power of the transmitter (dBm) at 1 meter
+     * @return the distance at which that rssi value would occur in meters
+     */
+    public static double distanceFromRssi(int rssi, int calibratedTxPower) {
+        return Math.pow(10, (calibratedTxPower - rssi) / 20.0);
+    }
+
+    /**
+     * Determine the region of a beacon given its perceived distance.
+     *
+     * @param distance The measured distance in meters.
+     * @return the region as one of the constants in {@link Region}.
+     */
+    public static int regionFromDistance(double distance) {
+        if (distance < 0) {
+            return Region.UNKNOWN;
+        }
+        if (distance <= NEAR_TO_MID_METERS) {
+            return Region.NEAR;
+        }
+        if (distance <= MID_TO_FAR_METERS) {
+            return Region.MID;
+        }
+        return Region.FAR;
+    }
+
+    /**
+     * Convert distance to RSSI using the free space path loss equation. See <a
+     * href="http://en.wikipedia.org/wiki/Free-space_path_loss">Free-space_path_loss</a>
+     *
+     * @param distanceInMeters distance in meters (m)
+     * @param calibratedTxPower transmitted power (dBm) calibrated to 1 meter
+     * @return the rssi (dBm) that would be measured at that distance
+     */
+    public static int rssiFromDistance(double distanceInMeters, int calibratedTxPower) {
+        return distanceInMeters == 0
+                ? calibratedTxPower + PATH_LOSS_AT_1M
+                : (int) (calibratedTxPower - (20 * Math.log10(distanceInMeters)));
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/ble/util/StringUtils.java b/nearby/service/java/com/android/server/nearby/common/ble/util/StringUtils.java
new file mode 100644
index 0000000..4d90b6d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/ble/util/StringUtils.java
@@ -0,0 +1,70 @@
+/*
+ * 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.common.ble.util;
+
+import android.annotation.Nullable;
+import android.util.SparseArray;
+
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.Map;
+
+/** Helper class for Bluetooth LE utils. */
+public final class StringUtils {
+    private StringUtils() {
+    }
+
+    /** Returns a string composed from a {@link SparseArray}. */
+    public static String toString(@Nullable SparseArray<byte[]> array) {
+        if (array == null) {
+            return "null";
+        }
+        if (array.size() == 0) {
+            return "{}";
+        }
+        StringBuilder buffer = new StringBuilder();
+        buffer.append('{');
+        for (int i = 0; i < array.size(); ++i) {
+            buffer.append(array.keyAt(i)).append("=").append(Arrays.toString(array.valueAt(i)));
+        }
+        buffer.append('}');
+        return buffer.toString();
+    }
+
+    /** Returns a string composed from a {@link Map}. */
+    public static <T> String toString(@Nullable Map<T, byte[]> map) {
+        if (map == null) {
+            return "null";
+        }
+        if (map.isEmpty()) {
+            return "{}";
+        }
+        StringBuilder buffer = new StringBuilder();
+        buffer.append('{');
+        Iterator<Map.Entry<T, byte[]>> it = map.entrySet().iterator();
+        while (it.hasNext()) {
+            Map.Entry<T, byte[]> entry = it.next();
+            Object key = entry.getKey();
+            buffer.append(key).append("=").append(Arrays.toString(map.get(key)));
+            if (it.hasNext()) {
+                buffer.append(", ");
+            }
+        }
+        buffer.append('}');
+        return buffer.toString();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bloomfilter/BloomFilter.java b/nearby/service/java/com/android/server/nearby/common/bloomfilter/BloomFilter.java
new file mode 100644
index 0000000..6d4275f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bloomfilter/BloomFilter.java
@@ -0,0 +1,108 @@
+/*
+ * 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.common.bloomfilter;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.primitives.UnsignedInts;
+
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.BitSet;
+
+/**
+ * A bloom filter that gives access to the underlying BitSet.
+ */
+public class BloomFilter {
+    private static final Charset CHARSET = UTF_8;
+
+    /**
+     * Receives a value and converts it into an array of ints that will be converted to indexes for
+     * the filter.
+     */
+    public interface Hasher {
+        /**
+         * Generate hash value.
+         */
+        int[] getHashes(byte[] value);
+    }
+
+    // The backing data for this bloom filter. As additions are made, they're OR'd until it
+    // eventually reaches 0xFF.
+    private final BitSet mBits;
+    // The max length of bits.
+    private final int mBitLength;
+    // The hasher to use for converting a value into an array of hashes.
+    private final Hasher mHasher;
+
+    public BloomFilter(byte[] bytes, Hasher hasher) {
+        this.mBits = BitSet.valueOf(bytes);
+        this.mBitLength = bytes.length * 8;
+        this.mHasher = hasher;
+    }
+
+    /**
+     * Return the bloom filter check bit set as byte array.
+     */
+    public byte[] asBytes() {
+        // BitSet.toByteArray() truncates all the unset bits after the last set bit (eg. [0,0,1,0]
+        // becomes [0,0,1]) so we re-add those bytes if needed with Arrays.copy().
+        byte[] b = mBits.toByteArray();
+        if (b.length == mBitLength / 8) {
+            return b;
+        }
+        return Arrays.copyOf(b, mBitLength / 8);
+    }
+
+    /**
+     * Add string value to bloom filter hash.
+     */
+    public void add(String s) {
+        add(s.getBytes(CHARSET));
+    }
+
+    /**
+     * Adds value to bloom filter hash.
+     */
+    public void add(byte[] value) {
+        int[] hashes = mHasher.getHashes(value);
+        for (int hash : hashes) {
+            mBits.set(UnsignedInts.remainder(hash, mBitLength));
+        }
+    }
+
+    /**
+     * Check if the string format has collision.
+     */
+    public boolean possiblyContains(String s) {
+        return possiblyContains(s.getBytes(CHARSET));
+    }
+
+    /**
+     * Checks if value after hash will have collision.
+     */
+    public boolean possiblyContains(byte[] value) {
+        int[] hashes = mHasher.getHashes(value);
+        for (int hash : hashes) {
+            if (!mBits.get(UnsignedInts.remainder(hash, mBitLength))) {
+                return false;
+            }
+        }
+        return true;
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/bloomfilter/FastPairBloomFilterHasher.java b/nearby/service/java/com/android/server/nearby/common/bloomfilter/FastPairBloomFilterHasher.java
new file mode 100644
index 0000000..0ccee97
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bloomfilter/FastPairBloomFilterHasher.java
@@ -0,0 +1,41 @@
+/*
+ * 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.common.bloomfilter;
+
+import com.google.common.hash.Hashing;
+
+import java.nio.ByteBuffer;
+
+/**
+ * Hasher which hashes a value using SHA-256 and splits it into parts, each of which can be
+ * converted to an index.
+ */
+public class FastPairBloomFilterHasher implements BloomFilter.Hasher {
+
+    private static final int NUM_INDEXES = 8;
+
+    @Override
+    public int[] getHashes(byte[] value) {
+        byte[] hash = Hashing.sha256().hashBytes(value).asBytes();
+        ByteBuffer buffer = ByteBuffer.wrap(hash);
+        int[] hashes = new int[NUM_INDEXES];
+        for (int i = 0; i < NUM_INDEXES; i++) {
+            hashes[i] = buffer.getInt();
+        }
+        return hashes;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothConsts.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothConsts.java
new file mode 100644
index 0000000..3a02b18
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothConsts.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 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.common.bluetooth;
+
+import java.util.UUID;
+
+/**
+ * Bluetooth constants.
+ */
+public class BluetoothConsts {
+
+    /**
+     * Default MTU when value is unknown.
+     */
+    public static final int DEFAULT_MTU = 23;
+
+    // The following random uuids are used to indicate that the device has dynamic services.
+    /**
+     * UUID of dynamic service.
+     */
+    public static final UUID SERVICE_DYNAMIC_SERVICE =
+            UUID.fromString("00000100-0af3-11e5-a6c0-1697f925ec7b");
+
+    /**
+     * UUID of dynamic characteristic.
+     */
+    public static final UUID SERVICE_DYNAMIC_CHARACTERISTIC =
+            UUID.fromString("00002A05-0af3-11e5-a6c0-1697f925ec7b");
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothException.java
new file mode 100644
index 0000000..db2e1cc
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothException.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 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.common.bluetooth;
+
+/**
+ * {@link Exception} thrown during a Bluetooth operation.
+ */
+public class BluetoothException extends Exception {
+    /** Constructor. */
+    public BluetoothException(String message) {
+        super(message);
+    }
+
+    /** Constructor. */
+    public BluetoothException(String message, Throwable throwable) {
+        super(message, throwable);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothGattException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothGattException.java
new file mode 100644
index 0000000..5ac4882
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothGattException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 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.common.bluetooth;
+
+/**
+ * Exception for Bluetooth GATT operations.
+ */
+public class BluetoothGattException extends BluetoothException {
+    private final int mErrorCode;
+
+    /** Constructor. */
+    public BluetoothGattException(String message, int errorCode) {
+        super(message);
+        mErrorCode = errorCode;
+    }
+
+    /** Constructor. */
+    public BluetoothGattException(String message, int errorCode, Throwable cause) {
+        super(message, cause);
+        mErrorCode = errorCode;
+    }
+
+    /** Returns Gatt error code. */
+    public int getGattErrorCode() {
+        return mErrorCode;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothTimeoutException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothTimeoutException.java
new file mode 100644
index 0000000..30fd188
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/BluetoothTimeoutException.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 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.common.bluetooth;
+
+/**
+ * {@link Exception} thrown during a Bluetooth operation when a timeout occurs.
+ */
+public class BluetoothTimeoutException extends BluetoothException {
+
+    /** Constructor. */
+    public BluetoothTimeoutException(String message) {
+        super(message);
+    }
+
+    /** Constructor. */
+    public BluetoothTimeoutException(String message, Throwable throwable) {
+        super(message, throwable);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/ReservedUuids.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/ReservedUuids.java
new file mode 100644
index 0000000..249011a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/ReservedUuids.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 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.common.bluetooth;
+
+import java.util.UUID;
+
+/**
+ * Reserved UUIDS by BT SIG.
+ * <p>
+ * See https://developer.bluetooth.org for more details.
+ */
+public class ReservedUuids {
+    /** UUIDs reserved for services. */
+    public static class Services {
+        /**
+         * The Device Information Service exposes manufacturer and/or vendor info about a device.
+         * <p>
+         * See reserved UUID org.bluetooth.service.device_information.
+         */
+        public static final UUID DEVICE_INFORMATION = fromShortUuid((short) 0x180A);
+
+        /**
+         * Generic attribute service.
+         * <p>
+         * See reserved UUID org.bluetooth.service.generic_attribute.
+         */
+        public static final UUID GENERIC_ATTRIBUTE = fromShortUuid((short) 0x1801);
+    }
+
+    /** UUIDs reserved for characteristics. */
+    public static class Characteristics {
+        /**
+         * The value of this characteristic is a UTF-8 string representing the firmware revision for
+         * the firmware within the device.
+         * <p>
+         * See reserved UUID org.bluetooth.characteristic.firmware_revision_string.
+         */
+        public static final UUID FIRMWARE_REVISION_STRING = fromShortUuid((short) 0x2A26);
+
+        /**
+         * Service change characteristic.
+         * <p>
+         * See reserved UUID org.bluetooth.characteristic.gatt.service_changed.
+         */
+        public static final UUID SERVICE_CHANGE = fromShortUuid((short) 0x2A05);
+    }
+
+    /** UUIDs reserved for descriptors. */
+    public static class Descriptors {
+        /**
+         * This descriptor shall be persistent across connections for bonded devices. The Client
+         * Characteristic Configuration descriptor is unique for each client. A client may read and
+         * write this descriptor to determine and set the configuration for that client.
+         * Authentication and authorization may be required by the server to write this descriptor.
+         * The default value for the Client Characteristic Configuration descriptor is 0x00. Upon
+         * connection of non-binded clients, this descriptor is set to the default value.
+         * <p>
+         * See reserved UUID org.bluetooth.descriptor.gatt.client_characteristic_configuration.
+         */
+        public static final UUID CLIENT_CHARACTERISTIC_CONFIGURATION =
+                fromShortUuid((short) 0x2902);
+    }
+
+    /** The base 128-bit UUID representation of a 16-bit UUID */
+    public static final UUID BASE_16_BIT_UUID =
+            UUID.fromString("00000000-0000-1000-8000-00805F9B34FB");
+
+    /** Converts from short UUId to UUID. */
+    public static UUID fromShortUuid(short shortUuid) {
+        return new UUID(((((long) shortUuid) << 32) & 0x0000FFFF00000000L)
+                | ReservedUuids.BASE_16_BIT_UUID.getMostSignificantBits(),
+                ReservedUuids.BASE_16_BIT_UUID.getLeastSignificantBits());
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGenerator.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGenerator.java
new file mode 100644
index 0000000..28a9c33
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGenerator.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.generateKey;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AccountKeyCharacteristic;
+
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * This is to generate account key with fast-pair style.
+ */
+public final class AccountKeyGenerator {
+
+    // Generate a key where the first byte is always defined as the type, 0x04. This maintains 15
+    // bytes of entropy in the key while also allowing providers to verify that they have received
+    // a properly formatted key and decrypted it correctly, minimizing the risk of replay attacks.
+
+    /**
+     * Creates account key.
+     */
+    public static byte[] createAccountKey() throws NoSuchAlgorithmException {
+        byte[] accountKey = generateKey();
+        accountKey[0] = AccountKeyCharacteristic.TYPE;
+        return accountKey;
+    }
+
+    private AccountKeyGenerator() {
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AdditionalDataEncoder.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AdditionalDataEncoder.java
new file mode 100644
index 0000000..c9ccfd5
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AdditionalDataEncoder.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.NONCE_SIZE;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+/**
+ * Utilities for encoding/decoding the additional data packet and verifying both the data integrity
+ * and the authentication.
+ *
+ * <p>Additional Data packet is:
+ *
+ * <ol>
+ *   <li>AdditionalData_Packet[0 - 7]: the first 8-byte of HMAC.
+ *   <li>AdditionalData_Packet[8 - var]: the encrypted message by AES-CTR, with 8-byte nonce
+ *       appended to the front.
+ * </ol>
+ *
+ * See https://developers.google.com/nearby/fast-pair/spec#AdditionalData.
+ */
+public final class AdditionalDataEncoder {
+
+    static final int EXTRACT_HMAC_SIZE = 8;
+    static final int MAX_LENGTH_OF_DATA = 64;
+
+    /**
+     * Encodes the given data to additional data packet by the given secret.
+     */
+    static byte[] encodeAdditionalDataPacket(byte[] secret, byte[] additionalData)
+            throws GeneralSecurityException {
+        if (secret == null || secret.length != AesCtrMultipleBlockEncryption.KEY_LENGTH) {
+            throw new GeneralSecurityException(
+                    "Incorrect secret for encoding additional data packet, secret.length = "
+                            + (secret == null ? "NULL" : secret.length));
+        }
+
+        if ((additionalData == null)
+                || (additionalData.length == 0)
+                || (additionalData.length > MAX_LENGTH_OF_DATA)) {
+            throw new GeneralSecurityException(
+                    "Invalid data for encoding additional data packet, data = "
+                            + (additionalData == null ? "NULL" : additionalData.length));
+        }
+
+        byte[] encryptedData = AesCtrMultipleBlockEncryption.encrypt(secret, additionalData);
+        byte[] extractedHmac =
+                Arrays.copyOf(HmacSha256.build(secret, encryptedData), EXTRACT_HMAC_SIZE);
+
+        return concat(extractedHmac, encryptedData);
+    }
+
+    /**
+     * Decodes additional data packet by the given secret.
+     *
+     * @param secret AES-128 key used in the encryption to decrypt data
+     * @param additionalDataPacket additional data packet which is encoded by the given secret
+     * @return the data byte array decoded from the given packet
+     * @throws GeneralSecurityException if the given key or additional data packet is invalid for
+     * decoding
+     */
+    static byte[] decodeAdditionalDataPacket(byte[] secret, byte[] additionalDataPacket)
+            throws GeneralSecurityException {
+        if (secret == null || secret.length != AesCtrMultipleBlockEncryption.KEY_LENGTH) {
+            throw new GeneralSecurityException(
+                    "Incorrect secret for decoding additional data packet, secret.length = "
+                            + (secret == null ? "NULL" : secret.length));
+        }
+        if (additionalDataPacket == null
+                || additionalDataPacket.length <= EXTRACT_HMAC_SIZE
+                || additionalDataPacket.length
+                > (MAX_LENGTH_OF_DATA + EXTRACT_HMAC_SIZE + NONCE_SIZE)) {
+            throw new GeneralSecurityException(
+                    "Additional data packet size is incorrect, additionalDataPacket.length is "
+                            + (additionalDataPacket == null ? "NULL"
+                            : additionalDataPacket.length));
+        }
+
+        if (!verifyHmac(secret, additionalDataPacket)) {
+            throw new GeneralSecurityException(
+                    "Verify HMAC failed, could be incorrect key or packet.");
+        }
+        byte[] encryptedData =
+                Arrays.copyOfRange(
+                        additionalDataPacket, EXTRACT_HMAC_SIZE, additionalDataPacket.length);
+        return AesCtrMultipleBlockEncryption.decrypt(secret, encryptedData);
+    }
+
+    // Computes the HMAC of the given key and additional data, and compares the first 8-byte of the
+    // HMAC result with the one from additional data packet.
+    // Must call constant-time comparison to prevent a possible timing attack, e.g. time the same
+    // MAC with all different first byte for a given ciphertext, the right one will take longer as
+    // it will fail on the second byte's verification.
+    private static boolean verifyHmac(byte[] key, byte[] additionalDataPacket)
+            throws GeneralSecurityException {
+        byte[] packetHmac =
+                Arrays.copyOfRange(additionalDataPacket, /* from= */ 0, EXTRACT_HMAC_SIZE);
+        byte[] encryptedData =
+                Arrays.copyOfRange(
+                        additionalDataPacket, EXTRACT_HMAC_SIZE, additionalDataPacket.length);
+        byte[] computedHmac = Arrays.copyOf(
+                HmacSha256.build(key, encryptedData), EXTRACT_HMAC_SIZE);
+
+        return HmacSha256.compareTwoHMACs(packetHmac, computedHmac);
+    }
+
+    private AdditionalDataEncoder() {
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AesCtrMultipleBlockEncryption.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AesCtrMultipleBlockEncryption.java
new file mode 100644
index 0000000..50a818b
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AesCtrMultipleBlockEncryption.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+/**
+ * AES-CTR utilities used for encrypting and decrypting Fast Pair packets that contain multiple
+ * blocks. Encrypts input data by:
+ *
+ * <ol>
+ *   <li>encryptedBlock[i] = clearBlock[i] ^ AES(counter), and
+ *   <li>concat(encryptedBlock[0], encryptedBlock[1],...) to create the encrypted result, where
+ *   <li>counter: the 16-byte input of AES. counter = iv + block_index.
+ *   <li>iv: extend 8-byte nonce to 16 bytes with zero padding. i.e. concat(0x0000000000000000,
+ *       nonce).
+ *   <li>nonce: the cryptographically random 8 bytes, must never be reused with the same key.
+ * </ol>
+ */
+final class AesCtrMultipleBlockEncryption {
+
+    /** Length for AES-128 key. */
+    static final int KEY_LENGTH = AesEcbSingleBlockEncryption.KEY_LENGTH;
+
+    @VisibleForTesting
+    static final int AES_BLOCK_LENGTH = AesEcbSingleBlockEncryption.AES_BLOCK_LENGTH;
+
+    /** Length of the nonce, a byte array of cryptographically random bytes. */
+    static final int NONCE_SIZE = 8;
+
+    private static final int IV_SIZE = AES_BLOCK_LENGTH;
+    private static final int MAX_NUMBER_OF_BLOCKS = 4;
+
+    private AesCtrMultipleBlockEncryption() {}
+
+    /** Generates a 16-byte AES key. */
+    static byte[] generateKey() throws NoSuchAlgorithmException {
+        return AesEcbSingleBlockEncryption.generateKey();
+    }
+
+    /**
+     * Encrypts data using AES-CTR by the given secret.
+     *
+     * @param secret AES-128 key.
+     * @param data the plaintext to be encrypted.
+     * @return the encrypted data with the 8-byte nonce appended to the front.
+     */
+    static byte[] encrypt(byte[] secret, byte[] data) throws GeneralSecurityException {
+        byte[] nonce = generateNonce();
+        return concat(nonce, doAesCtr(secret, data, nonce));
+    }
+
+    /**
+     * Decrypts data using AES-CTR by the given secret and nonce.
+     *
+     * @param secret AES-128 key.
+     * @param data the first 8 bytes is the nonce, and the remaining is the encrypted data to be
+     *     decrypted.
+     * @return the decrypted data.
+     */
+    static byte[] decrypt(byte[] secret, byte[] data) throws GeneralSecurityException {
+        if (data == null || data.length <= NONCE_SIZE) {
+            throw new GeneralSecurityException(
+                    "Incorrect data length "
+                            + (data == null ? "NULL" : data.length)
+                            + " to decrypt, the data should contain nonce.");
+        }
+        byte[] nonce = Arrays.copyOf(data, NONCE_SIZE);
+        byte[] encryptedData = Arrays.copyOfRange(data, NONCE_SIZE, data.length);
+        return doAesCtr(secret, encryptedData, nonce);
+    }
+
+    /**
+     * Generates cryptographically random NONCE_SIZE bytes nonce. This nonce can be used only once.
+     * Always call this function to generate a new nonce before a new encryption.
+     */
+    // Suppression for a warning for potentially insecure random numbers on Android 4.3 and older.
+    // Fast Pair service is only for Android 6.0+ devices.
+    static byte[] generateNonce() {
+        SecureRandom random = new SecureRandom();
+        byte[] nonce = new byte[NONCE_SIZE];
+        random.nextBytes(nonce);
+
+        return nonce;
+    }
+
+    // AES-CTR implementation.
+    @VisibleForTesting
+    static byte[] doAesCtr(byte[] secret, byte[] data, byte[] nonce)
+            throws GeneralSecurityException {
+        if (secret.length != KEY_LENGTH) {
+            throw new IllegalArgumentException(
+                    "Incorrect key length for encryption, only supports 16-byte AES Key.");
+        }
+        if (nonce.length != NONCE_SIZE) {
+            throw new IllegalArgumentException(
+                    "Incorrect nonce length for encryption, "
+                            + "Fast Pair naming scheme only supports 8-byte nonce.");
+        }
+
+        // Keeps the following operations on this byte[], returns it as the final AES-CTR result.
+        byte[] aesCtrResult = new byte[data.length];
+        System.arraycopy(data, /*srcPos=*/ 0, aesCtrResult, /*destPos=*/ 0, data.length);
+
+        // Initializes counter as IV.
+        byte[] counter = createIv(nonce);
+        // The length of the given data is permitted to non-align block size.
+        int numberOfBlocks =
+                (data.length / AES_BLOCK_LENGTH) + ((data.length % AES_BLOCK_LENGTH == 0) ? 0 : 1);
+
+        if (numberOfBlocks > MAX_NUMBER_OF_BLOCKS) {
+            throw new IllegalArgumentException(
+                    "Incorrect data size, Fast Pair naming scheme only supports 4 blocks.");
+        }
+
+        for (int i = 0; i < numberOfBlocks; i++) {
+            // Performs the operation: encryptedBlock[i] = clearBlock[i] ^ AES(counter).
+            counter[0] = (byte) (i & 0xFF);
+            byte[] aesOfCounter = doAesSingleBlock(secret, counter);
+            int start = i * AES_BLOCK_LENGTH;
+            // The size of the last block of data may not be 16 bytes. If not, still do xor to the
+            // last byte of data.
+            int end = Math.min(start + AES_BLOCK_LENGTH, data.length);
+            for (int j = 0; start < end; j++, start++) {
+                aesCtrResult[start] ^= aesOfCounter[j];
+            }
+        }
+        return aesCtrResult;
+    }
+
+    private static byte[] doAesSingleBlock(byte[] secret, byte[] counter)
+            throws GeneralSecurityException {
+        return AesEcbSingleBlockEncryption.encrypt(secret, counter);
+    }
+
+    /** Extends 8-byte nonce to 16 bytes with zero padding to create IV. */
+    private static byte[] createIv(byte[] nonce) {
+        return concat(new byte[IV_SIZE - NONCE_SIZE], nonce);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AesEcbSingleBlockEncryption.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AesEcbSingleBlockEncryption.java
new file mode 100644
index 0000000..547931e
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AesEcbSingleBlockEncryption.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.annotation.SuppressLint;
+
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Utilities used for encrypting and decrypting Fast Pair packets.
+ */
+// SuppressLint for ""ecb encryption mode should not be used".
+// Reasons:
+//    1. FastPair data is guaranteed to be only 1 AES block in size, ECB is secure.
+//    2. In each case, the encrypted data is less than 16-bytes and is
+//       padded up to 16-bytes using random data to fill the rest of the byte array,
+//       so the plaintext will never be the same.
+@SuppressLint("GetInstance")
+public final class AesEcbSingleBlockEncryption {
+
+    public static final int AES_BLOCK_LENGTH = 16;
+    public static final int KEY_LENGTH = 16;
+
+    private AesEcbSingleBlockEncryption() {
+    }
+
+    /**
+     * Generates a 16-byte AES key.
+     */
+    public static byte[] generateKey() throws NoSuchAlgorithmException {
+        KeyGenerator generator = KeyGenerator.getInstance("AES");
+        generator.init(KEY_LENGTH * 8); // Ensure a 16-byte key is always used.
+        return generator.generateKey().getEncoded();
+    }
+
+    /**
+     * Encrypts data with the provided secret.
+     */
+    public static byte[] encrypt(byte[] secret, byte[] data) throws GeneralSecurityException {
+        return doEncryption(Cipher.ENCRYPT_MODE, secret, data);
+    }
+
+    /**
+     * Decrypts data with the provided secret.
+     */
+    public static byte[] decrypt(byte[] secret, byte[] data) throws GeneralSecurityException {
+        return doEncryption(Cipher.DECRYPT_MODE, secret, data);
+    }
+
+    private static byte[] doEncryption(int mode, byte[] secret, byte[] data)
+            throws GeneralSecurityException {
+        if (data.length != AES_BLOCK_LENGTH) {
+            throw new IllegalArgumentException("This encrypter only supports 16-byte inputs.");
+        }
+        Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
+        cipher.init(mode, new SecretKeySpec(secret, "AES"));
+        return cipher.doFinal(data);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAddress.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAddress.java
new file mode 100644
index 0000000..9bb5a86
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAddress.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.provider.Settings;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.google.common.base.Ascii;
+import com.google.common.io.BaseEncoding;
+
+import java.util.Locale;
+
+/** Utils for dealing with Bluetooth addresses. */
+public final class BluetoothAddress {
+
+    private static final BaseEncoding ENCODING = base16().upperCase().withSeparator(":", 2);
+
+    @VisibleForTesting
+    static final String SECURE_SETTINGS_KEY_BLUETOOTH_ADDRESS = "bluetooth_address";
+
+    /**
+     * @return The string format used by e.g. {@link android.bluetooth.BluetoothDevice}. Upper case.
+     *     Example: "AA:BB:CC:11:22:33"
+     */
+    public static String encode(byte[] address) {
+        return ENCODING.encode(address);
+    }
+
+    /**
+     * @param address The string format used by e.g. {@link android.bluetooth.BluetoothDevice}.
+     *     Case-insensitive. Example: "AA:BB:CC:11:22:33"
+     */
+    public static byte[] decode(String address) {
+        return ENCODING.decode(address.toUpperCase(Locale.US));
+    }
+
+    /**
+     * Get public bluetooth address.
+     *
+     * @param context a valid {@link Context} instance.
+     */
+    public static @Nullable byte[] getPublicAddress(Context context) {
+        String publicAddress =
+                Settings.Secure.getString(
+                        context.getContentResolver(), SECURE_SETTINGS_KEY_BLUETOOTH_ADDRESS);
+        return publicAddress != null && BluetoothAdapter.checkBluetoothAddress(publicAddress)
+                ? decode(publicAddress)
+                : null;
+    }
+
+    /**
+     * Hides partial information of Bluetooth address.
+     * ex1: input is null, output should be empty string
+     * ex2: input is String(AA:BB:CC), output should be AA:BB:CC
+     * ex3: input is String(AA:BB:CC:DD:EE:FF), output should be XX:XX:XX:XX:EE:FF
+     * ex4: input is String(Aa:Bb:Cc:Dd:Ee:Ff), output should be XX:XX:XX:XX:EE:FF
+     * ex5: input is BluetoothDevice(AA:BB:CC:DD:EE:FF), output should be XX:XX:XX:XX:EE:FF
+     */
+    public static String maskBluetoothAddress(@Nullable Object address) {
+        if (address == null) {
+            return "";
+        }
+
+        if (address instanceof String) {
+            String originalAddress = (String) address;
+            String upperCasedAddress = Ascii.toUpperCase(originalAddress);
+            if (!BluetoothAdapter.checkBluetoothAddress(upperCasedAddress)) {
+                return originalAddress;
+            }
+            return convert(upperCasedAddress);
+        } else if (address instanceof BluetoothDevice) {
+            return convert(((BluetoothDevice) address).getAddress());
+        }
+
+        // For others, returns toString().
+        return address.toString();
+    }
+
+    private static String convert(String address) {
+        return "XX:XX:XX:XX:" + address.substring(12);
+    }
+
+    private BluetoothAddress() {}
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairer.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairer.java
new file mode 100644
index 0000000..07306c1
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairer.java
@@ -0,0 +1,774 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDING;
+import static android.bluetooth.BluetoothDevice.BOND_NONE;
+import static android.bluetooth.BluetoothDevice.ERROR;
+import static android.bluetooth.BluetoothProfile.A2DP;
+import static android.bluetooth.BluetoothProfile.HEADSET;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+
+import static java.util.concurrent.Executors.newSingleThreadExecutor;
+
+import android.Manifest.permission;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Parcelable;
+import android.os.SystemClock;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
+import androidx.core.content.ContextCompat;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.PasskeyCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.Profile;
+import com.android.server.nearby.common.bluetooth.fastpair.TimingLogger.ScopedTiming;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection.ChangeObserver;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ConnectErrorCode;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.CreateBondErrorCode;
+import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
+
+import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Pairs to Bluetooth audio devices.
+ */
+public class BluetoothAudioPairer {
+
+    private static final String TAG = BluetoothAudioPairer.class.getSimpleName();
+
+    /**
+     * Hidden, see {@link BluetoothDevice}.
+     */
+    // TODO(b/202549655): remove Hidden usage.
+    private static final String EXTRA_REASON = "android.bluetooth.device.extra.REASON";
+
+    /**
+     * Hidden, see {@link BluetoothDevice}.
+     */
+    // TODO(b/202549655): remove Hidden usage.
+    private static final int PAIRING_VARIANT_CONSENT = 3;
+
+    /**
+     * Hidden, see {@link BluetoothDevice}.
+     */
+    // TODO(b/202549655): remove Hidden usage.
+    public static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4;
+
+    private static final int DISCOVERY_STATE_CHANGE_TIMEOUT_MS = 3000;
+
+    private final Context mContext;
+    private final Preferences mPreferences;
+    private final EventLoggerWrapper mEventLogger;
+    private final BluetoothDevice mDevice;
+    @Nullable
+    private final KeyBasedPairingInfo mKeyBasedPairingInfo;
+    @Nullable
+    private final PasskeyConfirmationHandler mPasskeyConfirmationHandler;
+    private final TimingLogger mTimingLogger;
+
+    private static boolean sTestMode = false;
+
+    static void enableTestMode() {
+        sTestMode = true;
+    }
+
+    static class KeyBasedPairingInfo {
+
+        private final byte[] mSecret;
+        private final GattConnectionManager mGattConnectionManager;
+        private final boolean mProviderInitiatesBonding;
+
+        /**
+         * @param secret The secret negotiated during the initial BLE handshake for Key-based
+         * Pairing. See {@link FastPairConnection#handshake}.
+         * @param gattConnectionManager A manager that knows how to get and create Gatt connections
+         * to the remote device.
+         */
+        KeyBasedPairingInfo(
+                byte[] secret,
+                GattConnectionManager gattConnectionManager,
+                boolean providerInitiatesBonding) {
+            this.mSecret = secret;
+            this.mGattConnectionManager = gattConnectionManager;
+            this.mProviderInitiatesBonding = providerInitiatesBonding;
+        }
+    }
+
+    public BluetoothAudioPairer(
+            Context context,
+            BluetoothDevice device,
+            Preferences preferences,
+            EventLoggerWrapper eventLogger,
+            @Nullable KeyBasedPairingInfo keyBasedPairingInfo,
+            @Nullable PasskeyConfirmationHandler passkeyConfirmationHandler,
+            TimingLogger timingLogger)
+            throws PairingException {
+        this.mContext = context;
+        this.mDevice = device;
+        this.mPreferences = preferences;
+        this.mEventLogger = eventLogger;
+        this.mKeyBasedPairingInfo = keyBasedPairingInfo;
+        this.mPasskeyConfirmationHandler = passkeyConfirmationHandler;
+        this.mTimingLogger = timingLogger;
+
+        // TODO(b/203455314): follow up with the following comments.
+        // The OS should give the user some UI to choose if they want to allow access, but there
+        // seems to be a bug where if we don't reject access, it's auto-granted in some cases
+        // (Plantronics headset gets contacts access when pairing with my Taimen via Bluetooth
+        // Settings, without me seeing any UI about it). b/64066631
+        //
+        // If that OS bug doesn't get fixed, we can flip these flags to force-reject the
+        // permissions.
+        if (preferences.getRejectPhonebookAccess() && (sTestMode ? false :
+                !device.setPhonebookAccessPermission(BluetoothDevice.ACCESS_REJECTED))) {
+            throw new PairingException("Failed to deny contacts (phonebook) access.");
+        }
+        if (preferences.getRejectMessageAccess()
+                && (sTestMode ? false :
+                !device.setMessageAccessPermission(BluetoothDevice.ACCESS_REJECTED))) {
+            throw new PairingException("Failed to deny message access.");
+        }
+        if (preferences.getRejectSimAccess()
+                && (sTestMode ? false :
+                !device.setSimAccessPermission(BluetoothDevice.ACCESS_REJECTED))) {
+            throw new PairingException("Failed to deny SIM access.");
+        }
+    }
+
+    boolean isPaired() {
+        return (sTestMode ? false : mDevice.getBondState() == BOND_BONDED);
+    }
+
+    /**
+     * Unpairs from the device. Throws an exception if any error occurs.
+     */
+    @WorkerThread
+    void unpair()
+            throws InterruptedException, ExecutionException, TimeoutException, PairingException {
+        int bondState =  sTestMode ? BOND_NONE : mDevice.getBondState();
+        try (UnbondedReceiver unbondedReceiver = new UnbondedReceiver();
+                ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                        "Unpair for state: " + bondState)) {
+            // We'll only get a state change broadcast if we're actually unbonding (method returns
+            // true).
+            if (bondState == BluetoothDevice.BOND_BONDED) {
+                mEventLogger.setCurrentEvent(EventCode.REMOVE_BOND);
+                Log.i(TAG,  "removeBond with " + maskBluetoothAddress(mDevice));
+                mDevice.removeBond();
+                unbondedReceiver.await(
+                        mPreferences.getRemoveBondTimeoutSeconds(), TimeUnit.SECONDS);
+            } else if (bondState == BluetoothDevice.BOND_BONDING) {
+                mEventLogger.setCurrentEvent(EventCode.CANCEL_BOND);
+                Log.i(TAG,  "cancelBondProcess with " + maskBluetoothAddress(mDevice));
+                mDevice.cancelBondProcess();
+                unbondedReceiver.await(
+                        mPreferences.getRemoveBondTimeoutSeconds(), TimeUnit.SECONDS);
+            } else {
+                // The OS may have beaten us in a race, unbonding before we called the method. So if
+                // we're (somehow) in the desired state then we're happy, if not then bail.
+                if (bondState != BluetoothDevice.BOND_NONE) {
+                    throw new PairingException("returned false, state=%s", bondState);
+                }
+            }
+        }
+
+        // This seems to improve the probability that createBond will succeed after removeBond.
+        SystemClock.sleep(mPreferences.getRemoveBondSleepMillis());
+        mEventLogger.logCurrentEventSucceeded();
+    }
+
+    /**
+     * Pairs with the device. Throws an exception if any error occurs.
+     */
+    @WorkerThread
+    void pair()
+            throws InterruptedException, ExecutionException, TimeoutException, PairingException {
+        // Unpair first, because if we have a bond, but the other device has forgotten its bond,
+        // it can send us a pairing request that we're not ready for (which can pop up a dialog).
+        // Or, if we're in the middle of a (too-long) bonding attempt, we want to cancel.
+        unpair();
+
+        mEventLogger.setCurrentEvent(EventCode.CREATE_BOND);
+        try (BondedReceiver bondedReceiver = new BondedReceiver();
+                ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Create bond")) {
+            // If the provider's initiating the bond, we do nothing but wait for broadcasts.
+            if (mKeyBasedPairingInfo == null || !mKeyBasedPairingInfo.mProviderInitiatesBonding) {
+                if (!sTestMode) {
+                    Log.i(TAG, "createBond with " + maskBluetoothAddress(mDevice) + ", type="
+                        + mDevice.getType());
+                    if (mPreferences.getSpecifyCreateBondTransportType()) {
+                        mDevice.createBond(mPreferences.getCreateBondTransportType());
+                    } else {
+                        mDevice.createBond();
+                    }
+                }
+            }
+            try {
+                bondedReceiver.await(mPreferences.getCreateBondTimeoutSeconds(), TimeUnit.SECONDS);
+            } catch (TimeoutException e) {
+                Log.w(TAG, "bondedReceiver time out after " + mPreferences
+                        .getCreateBondTimeoutSeconds() + " seconds");
+                if (mPreferences.getIgnoreUuidTimeoutAfterBonded() && isPaired()) {
+                    Log.w(TAG, "Created bond but never received UUIDs, attempting to continue.");
+                } else {
+                    // Rethrow e to cause the pairing to fail and be retried if necessary.
+                    throw e;
+                }
+            }
+        }
+        mEventLogger.logCurrentEventSucceeded();
+    }
+
+    /**
+     * Connects to the given profile. Throws an exception if any error occurs.
+     *
+     * <p>If remote device clears the link key, the BOND_BONDED state would transit to BOND_BONDING
+     * (and go through the pairing process again) when directly connecting the profile. By enabling
+     * enablePairingBehavior, we provide both pairing and connecting behaviors at the same time. See
+     * b/145699390 for more details.
+     */
+    // Suppression for possible null from ImmutableMap#get. See go/lsc-get-nullable
+    @SuppressWarnings("nullness:argument")
+    @WorkerThread
+    public void connect(short profileUuid, boolean enablePairingBehavior)
+            throws InterruptedException, ReflectionException, TimeoutException, ExecutionException,
+            ConnectException {
+        if (!mPreferences.isSupportedProfile(profileUuid)) {
+            throw new ConnectException(
+                    ConnectErrorCode.UNSUPPORTED_PROFILE, "Unsupported profile=%s", profileUuid);
+        }
+        Profile profile = Constants.PROFILES.get(profileUuid);
+        Log.i(TAG,
+                "Connecting to profile=" + profile + " on device=" + maskBluetoothAddress(mDevice));
+        try (BondedReceiver bondedReceiver = enablePairingBehavior ? new BondedReceiver() : null;
+                ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                        "Connect: " + profile)) {
+            connectByProfileProxy(profile);
+        }
+    }
+
+    private void connectByProfileProxy(Profile profile)
+            throws ReflectionException, InterruptedException, ExecutionException, TimeoutException,
+            ConnectException {
+        try (BluetoothProfileWrapper autoClosingProxy = new BluetoothProfileWrapper(profile);
+                ConnectedReceiver connectedReceiver = new ConnectedReceiver(profile)) {
+            BluetoothProfile proxy = autoClosingProxy.mProxy;
+
+            // Try to connect via reflection
+            Log.v(TAG, "Connect to proxy=" + proxy);
+
+            if (!sTestMode) {
+                if (!(Boolean) Reflect.on(proxy).withMethod("connect", BluetoothDevice.class)
+                        .get(mDevice)) {
+                    // If we're already connecting, connect() may return false. :/
+                    Log.w(TAG, "connect returned false, expected if connecting, state="
+                            + proxy.getConnectionState(mDevice));
+                }
+            }
+
+            // If we're already connected, the OS may not send the connection state broadcast, so
+            // return immediately for that case.
+            if (!sTestMode) {
+                if (proxy.getConnectionState(mDevice) == BluetoothProfile.STATE_CONNECTED) {
+                    Log.v(TAG, "connectByProfileProxy: already connected to device="
+                            + maskBluetoothAddress(mDevice));
+                    return;
+                }
+            }
+
+            try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Wait connection")) {
+                // Wait for connecting to succeed or fail (via event or timeout).
+                connectedReceiver
+                        .await(mPreferences.getCreateBondTimeoutSeconds(), TimeUnit.SECONDS);
+            }
+        }
+    }
+
+    private class BluetoothProfileWrapper implements AutoCloseable {
+
+        // incompatible types in assignment.
+        @SuppressWarnings("nullness:assignment")
+        private final BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+
+        private final Profile mProfile;
+        private final BluetoothProfile mProxy;
+
+        /**
+         * Blocks until we get the proxy. Throws on error.
+         */
+        private BluetoothProfileWrapper(Profile profile)
+                throws InterruptedException, ExecutionException, TimeoutException,
+                ConnectException {
+            this.mProfile = profile;
+            mProxy = getProfileProxy(profile);
+        }
+
+        @Override
+        public void close() {
+            try (ScopedTiming scopedTiming =
+                    new ScopedTiming(mTimingLogger, "Close profile: " + mProfile)) {
+                if (!sTestMode) {
+                    mBluetoothAdapter.closeProfileProxy(mProfile.type, mProxy);
+                }
+            }
+        }
+
+        private BluetoothProfile getProfileProxy(BluetoothProfileWrapper this, Profile profile)
+                throws InterruptedException, ExecutionException, TimeoutException,
+                ConnectException {
+            if (profile.type != A2DP && profile.type != HEADSET) {
+                throw new IllegalArgumentException("Unsupported profile type=" + profile.type);
+            }
+
+            SettableFuture<BluetoothProfile> proxyFuture = SettableFuture.create();
+            BluetoothProfile.ServiceListener listener =
+                    new BluetoothProfile.ServiceListener() {
+                        @UiThread
+                        @Override
+                        public void onServiceConnected(int profileType, BluetoothProfile proxy) {
+                            proxyFuture.set(proxy);
+                        }
+
+                        @Override
+                        public void onServiceDisconnected(int profileType) {
+                            Log.v(TAG, "proxy disconnected for profile=" + profile);
+                        }
+                    };
+
+            if (!mBluetoothAdapter.getProfileProxy(mContext, listener, profile.type)) {
+                throw new ConnectException(
+                        ConnectErrorCode.GET_PROFILE_PROXY_FAILED,
+                        "getProfileProxy failed immediately");
+            }
+
+            return proxyFuture.get(mPreferences.getProxyTimeoutSeconds(), TimeUnit.SECONDS);
+        }
+    }
+
+    private class UnbondedReceiver extends DeviceIntentReceiver {
+
+        private UnbondedReceiver() {
+            super(mContext, mPreferences, mDevice, BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+        }
+
+        @Override
+        protected void onReceiveDeviceIntent(Intent intent) throws Exception {
+            if (mDevice.getBondState() == BOND_NONE) {
+                try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                        "Close UnbondedReceiver")) {
+                    close();
+                }
+            }
+        }
+    }
+
+    /**
+     * Receiver that closes after bonding has completed.
+     */
+    class BondedReceiver extends DeviceIntentReceiver {
+
+        private boolean mReceivedUuids = false;
+        private boolean mReceivedPasskey = false;
+
+        private BondedReceiver() {
+            super(
+                    mContext,
+                    mPreferences,
+                    mDevice,
+                    BluetoothDevice.ACTION_PAIRING_REQUEST,
+                    BluetoothDevice.ACTION_BOND_STATE_CHANGED,
+                    BluetoothDevice.ACTION_UUID);
+        }
+
+        // switching on a possibly-null value (intent.getAction())
+        // incompatible types in argument.
+        @SuppressWarnings({"nullness:switching.nullable", "nullness:argument"})
+        @Override
+        protected void onReceiveDeviceIntent(Intent intent)
+                throws PairingException, InterruptedException, ExecutionException, TimeoutException,
+                BluetoothException, GeneralSecurityException {
+            switch (intent.getAction()) {
+                case BluetoothDevice.ACTION_PAIRING_REQUEST:
+                    int variant = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, ERROR);
+                    int passkey = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_KEY, ERROR);
+                    handlePairingRequest(variant, passkey);
+                    break;
+                case BluetoothDevice.ACTION_BOND_STATE_CHANGED:
+                    // Use the state in the intent, not device.getBondState(), to avoid a race where
+                    // we log the wrong failure reason during a rapid transition.
+                    int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, ERROR);
+                    int reason = intent.getIntExtra(EXTRA_REASON, ERROR);
+                    handleBondStateChanged(bondState, reason);
+                    break;
+                case BluetoothDevice.ACTION_UUID:
+                    // According to eisenbach@ and pavlin@, there's always a UUID broadcast when
+                    // pairing (it can happen either before or after the transition to BONDED).
+                    if (mPreferences.getWaitForUuidsAfterBonding()) {
+                        Parcelable[] uuids = intent
+                                .getParcelableArrayExtra(BluetoothDevice.EXTRA_UUID);
+                        handleUuids(uuids);
+                    }
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        private void handlePairingRequest(int variant, int passkey) {
+            Log.i(TAG, "Pairing request, variant=" + variant + ", passkey=" + (passkey == ERROR
+                    ? "(none)" : String.valueOf(passkey)));
+            if (mPreferences.getMoreEventLogForQuality()) {
+                mEventLogger.setCurrentEvent(EventCode.HANDLE_PAIRING_REQUEST);
+            }
+
+            if (mPreferences.getSupportHidDevice() && variant == PAIRING_VARIANT_DISPLAY_PASSKEY) {
+                mReceivedPasskey = true;
+                extendAwaitSecond(
+                        mPreferences.getHidCreateBondTimeoutSeconds()
+                                - mPreferences.getCreateBondTimeoutSeconds());
+                triggerDiscoverStateChange();
+                if (mPreferences.getMoreEventLogForQuality()) {
+                    mEventLogger.logCurrentEventSucceeded();
+                }
+                return;
+
+            } else {
+                // Prevent Bluetooth Settings from getting the pairing request and showing its own
+                // UI.
+                abortBroadcast();
+
+                if (variant == PAIRING_VARIANT_CONSENT
+                        && mKeyBasedPairingInfo == null // Fast Pair 1.0 device
+                        && mPreferences.getAcceptConsentForFastPairOne()) {
+                    // Previously, if Bluetooth decided to use the Just Works variant (e.g. Fast
+                    // Pair 1.0), we don't get a pairing request broadcast at all.
+                    // However, after CVE-2019-2225, Bluetooth will decide to ask consent from
+                    // users. Details:
+                    // https://source.android.com/security/bulletin/2019-12-01#system
+                    // Since we've certified the Fast Pair 1.0 devices, and user taps to pair it
+                    // (with the device's image), we could help user to accept the consent.
+                    if (!sTestMode) {
+                        mDevice.setPairingConfirmation(true);
+                    }
+                    if (mPreferences.getMoreEventLogForQuality()) {
+                        mEventLogger.logCurrentEventSucceeded();
+                    }
+                    return;
+                } else if (variant != BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION) {
+                    if (!sTestMode) {
+                        mDevice.setPairingConfirmation(false);
+                    }
+                    if (mPreferences.getMoreEventLogForQuality()) {
+                        mEventLogger.logCurrentEventFailed(
+                                new CreateBondException(
+                                        CreateBondErrorCode.INCORRECT_VARIANT, 0,
+                                        "Incorrect variant for FastPair"));
+                    }
+                    return;
+                }
+                mReceivedPasskey = true;
+
+                if (mKeyBasedPairingInfo == null) {
+                    if (mPreferences.getAcceptPasskey()) {
+                        // Must be the simulator using FP 1.0 (no Key-based Pairing). Real
+                        // headphones using FP 1.0 use Just Works instead (and maybe we should
+                        // disable this flag for them).
+                        if (!sTestMode) {
+                            mDevice.setPairingConfirmation(true);
+                        }
+                    }
+                    if (mPreferences.getMoreEventLogForQuality()) {
+                        if (!sTestMode) {
+                            mEventLogger.logCurrentEventSucceeded();
+                        }
+                    }
+                    return;
+                }
+            }
+
+            if (mPreferences.getMoreEventLogForQuality()) {
+                mEventLogger.logCurrentEventSucceeded();
+            }
+
+            newSingleThreadExecutor()
+                    .execute(
+                            () -> {
+                                try (ScopedTiming scopedTiming1 =
+                                        new ScopedTiming(mTimingLogger, "Exchange passkey")) {
+                                    mEventLogger.setCurrentEvent(EventCode.PASSKEY_EXCHANGE);
+
+                                    // We already check above, but the static analyzer's not
+                                    // convinced without this.
+                                    Preconditions.checkNotNull(mKeyBasedPairingInfo);
+                                    BluetoothGattConnection connection =
+                                            mKeyBasedPairingInfo.mGattConnectionManager
+                                                    .getConnection();
+                                    UUID characteristicUuid =
+                                            PasskeyCharacteristic.getId(connection);
+                                    ChangeObserver remotePasskeyObserver =
+                                            connection.enableNotification(FastPairService.ID,
+                                                    characteristicUuid);
+                                    Log.i(TAG, "Sending local passkey.");
+                                    byte[] encryptedData;
+                                    try (ScopedTiming scopedTiming2 =
+                                            new ScopedTiming(mTimingLogger, "Encrypt passkey")) {
+                                        encryptedData =
+                                                PasskeyCharacteristic.encrypt(
+                                                        PasskeyCharacteristic.Type.SEEKER,
+                                                        mKeyBasedPairingInfo.mSecret, passkey);
+                                    }
+                                    try (ScopedTiming scopedTiming3 =
+                                            new ScopedTiming(mTimingLogger,
+                                                    "Send passkey to remote")) {
+                                        connection.writeCharacteristic(
+                                                FastPairService.ID, characteristicUuid,
+                                                encryptedData);
+                                    }
+                                    Log.i(TAG, "Waiting for remote passkey.");
+                                    byte[] encryptedRemotePasskey;
+                                    try (ScopedTiming scopedTiming4 =
+                                            new ScopedTiming(mTimingLogger,
+                                                    "Wait for remote passkey")) {
+                                        encryptedRemotePasskey =
+                                                remotePasskeyObserver.waitForUpdate(
+                                                        TimeUnit.SECONDS.toMillis(mPreferences
+                                                                .getGattOperationTimeoutSeconds()));
+                                    }
+                                    int remotePasskey;
+                                    try (ScopedTiming scopedTiming5 =
+                                            new ScopedTiming(mTimingLogger, "Decrypt passkey")) {
+                                        remotePasskey =
+                                                PasskeyCharacteristic.decrypt(
+                                                        PasskeyCharacteristic.Type.PROVIDER,
+                                                        mKeyBasedPairingInfo.mSecret,
+                                                        encryptedRemotePasskey);
+                                    }
+
+                                    // We log success if we made it through with no exceptions.
+                                    // If the passkey was wrong, pairing will fail and we'll log
+                                    // BOND_BROKEN with reason = AUTH_FAILED.
+                                    mEventLogger.logCurrentEventSucceeded();
+
+                                    boolean isPasskeyCorrect = passkey == remotePasskey;
+                                    if (isPasskeyCorrect) {
+                                        Log.i(TAG, "Passkey correct.");
+                                    } else {
+                                        Log.e(TAG, "Passkey incorrect, local= " + passkey
+                                                + ", remote=" + remotePasskey);
+                                    }
+
+                                    // Don't estimate the {@code ScopedTiming} because the
+                                    // passkey confirmation is done by UI.
+                                    if (isPasskeyCorrect
+                                            && mPreferences.getHandlePasskeyConfirmationByUi()
+                                            && mPasskeyConfirmationHandler != null) {
+                                        Log.i(TAG, "Callback the passkey to UI for confirmation.");
+                                        mPasskeyConfirmationHandler
+                                                .onPasskeyConfirmation(mDevice, passkey);
+                                    } else {
+                                        try (ScopedTiming scopedTiming6 =
+                                                new ScopedTiming(
+                                                        mTimingLogger, "Confirm the pairing: "
+                                                        + isPasskeyCorrect)) {
+                                            mDevice.setPairingConfirmation(isPasskeyCorrect);
+                                        }
+                                    }
+                                } catch (BluetoothException
+                                        | GeneralSecurityException
+                                        | InterruptedException
+                                        | ExecutionException
+                                        | TimeoutException e) {
+                                    mEventLogger.logCurrentEventFailed(e);
+                                    closeWithError(e);
+                                }
+                            });
+        }
+
+        /**
+         * Workaround to let Settings popup a pairing dialog instead of notification. When pairing
+         * request intent passed to Settings, it'll check several conditions to decide that it
+         * should show a dialog or a notification. One of those conditions is to check if the device
+         * is in discovery mode recently, which can be fulfilled by calling {@link
+         * BluetoothAdapter#startDiscovery()}. This method aims to fulfill the condition, and block
+         * the pairing broadcast for at most
+         * {@link BluetoothAudioPairer#DISCOVERY_STATE_CHANGE_TIMEOUT_MS}
+         * to make sure that we fulfill the condition first and successful.
+         */
+        // dereference of possibly-null reference bluetoothAdapter
+        @SuppressWarnings("nullness:dereference.of.nullable")
+        private void triggerDiscoverStateChange() {
+            BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+
+            if (bluetoothAdapter.isDiscovering()) {
+                return;
+            }
+
+            HandlerThread backgroundThread = new HandlerThread("TriggerDiscoverStateChangeThread");
+            backgroundThread.start();
+
+            AtomicBoolean result = new AtomicBoolean(false);
+            SimpleBroadcastReceiver receiver =
+                    new SimpleBroadcastReceiver(
+                            mContext,
+                            mPreferences,
+                            new Handler(backgroundThread.getLooper()),
+                            BluetoothAdapter.ACTION_DISCOVERY_STARTED,
+                            BluetoothAdapter.ACTION_DISCOVERY_FINISHED) {
+
+                        @Override
+                        protected void onReceive(Intent intent) throws Exception {
+                            result.set(true);
+                            close();
+                        }
+                    };
+
+            Log.i(TAG, "triggerDiscoverStateChange call startDiscovery.");
+            // Uses startDiscovery to trigger Settings show pairing dialog instead of notification.
+            if (!sTestMode) {
+                bluetoothAdapter.startDiscovery();
+                bluetoothAdapter.cancelDiscovery();
+            }
+            try {
+                receiver.await(DISCOVERY_STATE_CHANGE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+            } catch (InterruptedException | ExecutionException | TimeoutException e) {
+                Log.w(TAG, "triggerDiscoverStateChange failed!");
+            }
+
+            backgroundThread.quitSafely();
+            try {
+                backgroundThread.join();
+            } catch (InterruptedException e) {
+                Log.i(TAG, "triggerDiscoverStateChange backgroundThread.join meet exception!", e);
+            }
+
+            if (result.get()) {
+                Log.i(TAG, "triggerDiscoverStateChange successful.");
+            }
+        }
+
+        private void handleBondStateChanged(int bondState, int reason)
+                throws PairingException, InterruptedException, ExecutionException,
+                TimeoutException {
+            Log.i(TAG, "Bond state changed to " + bondState + ", reason=" + reason);
+            switch (bondState) {
+                case BOND_BONDED:
+                    if (mKeyBasedPairingInfo != null && !mReceivedPasskey) {
+                        // The device bonded with Just Works, although we did the Key-based Pairing
+                        // GATT handshake and agreed on a pairing secret. It might be a Person In
+                        // The Middle Attack!
+                        try (ScopedTiming scopedTiming =
+                                new ScopedTiming(mTimingLogger,
+                                        "Close BondedReceiver: POSSIBLE_MITM")) {
+                            closeWithError(
+                                    new CreateBondException(
+                                            CreateBondErrorCode.POSSIBLE_MITM,
+                                            reason,
+                                            "Unexpectedly bonded without a passkey. It might be a "
+                                                    + "Person In The Middle Attack! Unbonding!"));
+                        }
+                        unpair();
+                    } else if (!mPreferences.getWaitForUuidsAfterBonding()
+                            || (mPreferences.getReceiveUuidsAndBondedEventBeforeClose()
+                            && mReceivedUuids)) {
+                        try (ScopedTiming scopedTiming =
+                                new ScopedTiming(mTimingLogger, "Close BondedReceiver")) {
+                            close();
+                        }
+                    }
+                    break;
+                case BOND_NONE:
+                    throw new CreateBondException(
+                            CreateBondErrorCode.BOND_BROKEN, reason, "Bond broken, reason=%d",
+                            reason);
+                case BOND_BONDING:
+                default:
+                    break;
+            }
+        }
+
+        private void handleUuids(Parcelable[] uuids) {
+            Log.i(TAG, "Got UUIDs for " + maskBluetoothAddress(mDevice) + ": "
+                    + Arrays.toString(uuids));
+            mReceivedUuids = true;
+            if (!mPreferences.getReceiveUuidsAndBondedEventBeforeClose() || isPaired()) {
+                try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                        "Close BondedReceiver")) {
+                    close();
+                }
+            }
+        }
+    }
+
+    private class ConnectedReceiver extends DeviceIntentReceiver {
+
+        private ConnectedReceiver(Profile profile) throws ConnectException {
+            super(mContext, mPreferences, mDevice, profile.connectionStateAction);
+        }
+
+        @Override
+        public void onReceiveDeviceIntent(Intent intent) throws PairingException {
+            int state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, ERROR);
+            Log.i(TAG, "Connection state changed to " + state);
+            switch (state) {
+                case BluetoothAdapter.STATE_CONNECTED:
+                    try (ScopedTiming scopedTiming =
+                            new ScopedTiming(mTimingLogger, "Close ConnectedReceiver")) {
+                        close();
+                    }
+                    break;
+                case BluetoothAdapter.STATE_DISCONNECTED:
+                    throw new ConnectException(ConnectErrorCode.DISCONNECTED, "Disconnected");
+                case BluetoothAdapter.STATE_CONNECTING:
+                case BluetoothAdapter.STATE_DISCONNECTING:
+                default:
+                    break;
+            }
+        }
+    }
+
+    private boolean hasPermission(String permission) {
+        return ContextCompat.checkSelfPermission(mContext, permission) == PERMISSION_GRANTED;
+    }
+
+    public BluetoothDevice getDevice() {
+        return mDevice;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairer.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairer.java
new file mode 100644
index 0000000..6c467d3
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairer.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDING;
+import static android.bluetooth.BluetoothDevice.BOND_NONE;
+import static android.bluetooth.BluetoothDevice.ERROR;
+import static android.bluetooth.BluetoothDevice.EXTRA_DEVICE;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.annotation.WorkerThread;
+
+import com.google.common.base.Strings;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Pairs to Bluetooth classic devices with passkey confirmation.
+ */
+// TODO(b/202524672): Add class unit test.
+public class BluetoothClassicPairer {
+
+    private static final String TAG = BluetoothClassicPairer.class.getSimpleName();
+    /**
+     * Hidden, see {@link BluetoothDevice}.
+     */
+    private static final String EXTRA_REASON = "android.bluetooth.device.extra.REASON";
+
+    private final Context mContext;
+    private final BluetoothDevice mDevice;
+    private final Preferences mPreferences;
+    private final PasskeyConfirmationHandler mPasskeyConfirmationHandler;
+
+    public BluetoothClassicPairer(
+            Context context,
+            BluetoothDevice device,
+            Preferences preferences,
+            PasskeyConfirmationHandler passkeyConfirmationHandler) {
+        this.mContext = context;
+        this.mDevice = device;
+        this.mPreferences = preferences;
+        this.mPasskeyConfirmationHandler = passkeyConfirmationHandler;
+    }
+
+    /**
+     * Pairs with the device. Throws a {@link PairingException} if any error occurs.
+     */
+    @WorkerThread
+    public void pair() throws PairingException {
+        Log.i(TAG, "BluetoothClassicPairer, createBond with " + maskBluetoothAddress(mDevice)
+                + ", type=" + mDevice.getType());
+        try (BondedReceiver bondedReceiver = new BondedReceiver()) {
+            if (mDevice.createBond()) {
+                bondedReceiver.await(mPreferences.getCreateBondTimeoutSeconds(), SECONDS);
+            } else {
+                throw new PairingException(
+                        "BluetoothClassicPairer, createBond got immediate error");
+            }
+        } catch (TimeoutException | InterruptedException | ExecutionException e) {
+            throw new PairingException("BluetoothClassicPairer, createBond failed", e);
+        }
+    }
+
+    protected boolean isPaired() {
+        return mDevice.getBondState() == BOND_BONDED;
+    }
+
+    /**
+     * Receiver that closes after bonding has completed.
+     */
+    private class BondedReceiver extends DeviceIntentReceiver {
+
+        private BondedReceiver() {
+            super(
+                    mContext,
+                    mPreferences,
+                    mDevice,
+                    BluetoothDevice.ACTION_PAIRING_REQUEST,
+                    BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+        }
+
+        /**
+         * Called with ACTION_PAIRING_REQUEST and ACTION_BOND_STATE_CHANGED about the interesting
+         * device (see {@link DeviceIntentReceiver}).
+         *
+         * <p>The ACTION_PAIRING_REQUEST intent provides the passkey which will be sent to the
+         * {@link PasskeyConfirmationHandler} for showing the UI, and the ACTION_BOND_STATE_CHANGED
+         * will provide the result of the bonding.
+         */
+        @Override
+        protected void onReceiveDeviceIntent(Intent intent) {
+            String intentAction = intent.getAction();
+            BluetoothDevice remoteDevice = intent.getParcelableExtra(EXTRA_DEVICE);
+            if (Strings.isNullOrEmpty(intentAction)
+                    || remoteDevice == null
+                    || !remoteDevice.getAddress().equals(mDevice.getAddress())) {
+                Log.w(TAG,
+                        "BluetoothClassicPairer, receives " + intentAction
+                                + " from unexpected device " + maskBluetoothAddress(remoteDevice));
+                return;
+            }
+            switch (intentAction) {
+                case BluetoothDevice.ACTION_PAIRING_REQUEST:
+                    handlePairingRequest(
+                            remoteDevice,
+                            intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, ERROR),
+                            intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_KEY, ERROR));
+                    break;
+                case BluetoothDevice.ACTION_BOND_STATE_CHANGED:
+                    handleBondStateChanged(
+                            intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, ERROR),
+                            intent.getIntExtra(EXTRA_REASON, ERROR));
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        private void handlePairingRequest(BluetoothDevice device, int variant, int passkey) {
+            Log.i(TAG,
+                    "BluetoothClassicPairer, pairing request, " + device + ", " + variant + ", "
+                            + passkey);
+            // Prevent Bluetooth Settings from getting the pairing request and showing its own UI.
+            abortBroadcast();
+            mPasskeyConfirmationHandler.onPasskeyConfirmation(device, passkey);
+        }
+
+        private void handleBondStateChanged(int bondState, int reason) {
+            Log.i(TAG,
+                    "BluetoothClassicPairer, bond state changed to " + bondState + ", reason="
+                            + reason);
+            switch (bondState) {
+                case BOND_BONDING:
+                    // Don't close!
+                    return;
+                case BOND_BONDED:
+                    close();
+                    return;
+                case BOND_NONE:
+                default:
+                    closeWithError(
+                            new PairingException(
+                                    "BluetoothClassicPairer, createBond failed, reason:" + reason));
+            }
+        }
+    }
+
+    // Applies UsesPermission annotation will create circular dependency.
+    @SuppressLint("MissingPermission")
+    static void setPairingConfirmation(BluetoothDevice device, boolean confirm) {
+        Log.i(TAG, "BluetoothClassicPairer: setPairingConfirmation " + maskBluetoothAddress(device)
+                + ", confirm: " + confirm);
+        device.setPairingConfirmation(confirm);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothUuids.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothUuids.java
new file mode 100644
index 0000000..c5475a6
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothUuids.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import java.util.UUID;
+
+/**
+ * Utilities for dealing with UUIDs assigned by the Bluetooth SIG. Has a lot in common with
+ * com.android.BluetoothUuid, but that class is hidden.
+ */
+public class BluetoothUuids {
+
+    /**
+     * The Base UUID is used for calculating 128-bit UUIDs from "short UUIDs" (16- and 32-bit).
+     *
+     * @see {https://www.bluetooth.com/specifications/assigned-numbers/service-discovery}
+     */
+    private static final UUID BASE_UUID = UUID.fromString("00000000-0000-1000-8000-00805F9B34FB");
+
+    /**
+     * Fast Pair custom GATT characteristics 128-bit UUIDs base.
+     *
+     * <p>Notes: The 16-bit value locates at the 3rd and 4th bytes.
+     *
+     * @see {go/fastpair-128bit-gatt}
+     */
+    private static final UUID FAST_PAIR_BASE_UUID =
+            UUID.fromString("FE2C0000-8366-4814-8EB0-01DE32100BEA");
+
+    private static final int BIT_INDEX_OF_16_BIT_UUID = 32;
+
+    private BluetoothUuids() {}
+
+    /**
+     * Returns the 16-bit version of the UUID. If this is not a 16-bit UUID, throws
+     * IllegalArgumentException.
+     */
+    public static short get16BitUuid(UUID uuid) {
+        if (!is16BitUuid(uuid)) {
+            throw new IllegalArgumentException("Not a 16-bit Bluetooth UUID: " + uuid);
+        }
+        return (short) (uuid.getMostSignificantBits() >> BIT_INDEX_OF_16_BIT_UUID);
+    }
+
+    /** Checks whether the UUID is 16 bit */
+    public static boolean is16BitUuid(UUID uuid) {
+        // See Service Discovery Protocol in the Bluetooth Core Specification. Bits at index 32-48
+        // are the 16-bit UUID, and the rest must match the Base UUID.
+        return uuid.getLeastSignificantBits() == BASE_UUID.getLeastSignificantBits()
+                && (uuid.getMostSignificantBits() & 0xFFFF0000FFFFFFFFL)
+                == BASE_UUID.getMostSignificantBits();
+    }
+
+    /** Converts short UUID to 128 bit UUID */
+    public static UUID to128BitUuid(short shortUuid) {
+        return new UUID(
+                ((shortUuid & 0xFFFFL) << BIT_INDEX_OF_16_BIT_UUID)
+                        | BASE_UUID.getMostSignificantBits(), BASE_UUID.getLeastSignificantBits());
+    }
+
+    /** Transfers the 16-bit Fast Pair custom GATT characteristics to 128-bit. */
+    public static UUID toFastPair128BitUuid(short shortUuid) {
+        return new UUID(
+                ((shortUuid & 0xFFFFL) << BIT_INDEX_OF_16_BIT_UUID)
+                        | FAST_PAIR_BASE_UUID.getMostSignificantBits(),
+                FAST_PAIR_BASE_UUID.getLeastSignificantBits());
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BroadcastConstants.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BroadcastConstants.java
new file mode 100644
index 0000000..c26c6ad
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BroadcastConstants.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+/**
+ * Constants to share with the cloud syncing process.
+ */
+public class BroadcastConstants {
+
+    // TODO: Set right value for AOSP.
+    /** Package name of the cloud syncing logic. */
+    public static final String PACKAGE_NAME = "PACKAGE_NAME";
+    /** Service name of the cloud syncing instance. */
+    public static final String SERVICE_NAME = PACKAGE_NAME + ".SERVICE_NAME";
+    private static final String PREFIX = PACKAGE_NAME + ".PREFIX_NAME.";
+
+    /** Action when a fast pair device is added. */
+    public static final String ACTION_FAST_PAIR_DEVICE_ADDED =
+            PREFIX + "ACTION_FAST_PAIR_DEVICE_ADDED";
+    /**
+     * The BLE address of a device. BLE is used here instead of public because the caller of the
+     * library never knows what the device's public address is.
+     */
+    public static final String EXTRA_ADDRESS = PREFIX + "BLE_ADDRESS";
+    /** The public address of a device. */
+    public static final String EXTRA_PUBLIC_ADDRESS = PREFIX + "PUBLIC_ADDRESS";
+    /** Account key. */
+    public static final String EXTRA_ACCOUNT_KEY = PREFIX + "ACCOUNT_KEY";
+    /** Whether a paring is retroactive. */
+    public static final String EXTRA_RETROACTIVE_PAIR = PREFIX + "EXTRA_RETROACTIVE_PAIR";
+
+    private BroadcastConstants() {
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Bytes.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Bytes.java
new file mode 100644
index 0000000..637cd03
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Bytes.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.ShortBuffer;
+import java.util.Arrays;
+
+/** Represents a block of bytes, with hashCode and equals. */
+public abstract class Bytes {
+    private static final char[] sHexDigits = "0123456789abcdef".toCharArray();
+    private final byte[] mBytes;
+
+    /**
+     * A logical value consisting of one or more bytes in the given order (little-endian, i.e.
+     * LSO...MSO, or big-endian, i.e. MSO...LSO). E.g. the Fast Pair Model ID is a 3-byte value,
+     * and a Bluetooth device address is a 6-byte value.
+     */
+    public static class Value extends Bytes {
+        private final ByteOrder mByteOrder;
+
+        /**
+         * Constructor.
+         */
+        public Value(byte[] bytes, ByteOrder byteOrder) {
+            super(bytes);
+            this.mByteOrder = byteOrder;
+        }
+
+        /**
+         * Gets bytes.
+         */
+        public byte[] getBytes(ByteOrder byteOrder) {
+            return this.mByteOrder.equals(byteOrder) ? getBytes() : reverse(getBytes());
+        }
+
+        private static byte[] reverse(byte[] bytes) {
+            byte[] reversedBytes = new byte[bytes.length];
+            for (int i = 0; i < bytes.length; i++) {
+                reversedBytes[i] = bytes[bytes.length - i - 1];
+            }
+            return reversedBytes;
+        }
+    }
+
+    Bytes(byte[] bytes) {
+        mBytes = bytes;
+    }
+
+    private static String toHexString(byte[] bytes) {
+        StringBuilder sb = new StringBuilder(2 * bytes.length);
+        for (byte b : bytes) {
+            sb.append(sHexDigits[(b >> 4) & 0xf]).append(sHexDigits[b & 0xf]);
+        }
+        return sb.toString();
+    }
+
+    /** Returns 2-byte values in the same order, each using the given byte order. */
+    public static byte[] toBytes(ByteOrder byteOrder, short... shorts) {
+        ByteBuffer byteBuffer = ByteBuffer.allocate(shorts.length * 2).order(byteOrder);
+        for (short s : shorts) {
+            byteBuffer.putShort(s);
+        }
+        return byteBuffer.array();
+    }
+
+    /** Returns the shorts in the same order, each converted using the given byte order. */
+    static short[] toShorts(ByteOrder byteOrder, byte[] bytes) {
+        ShortBuffer shortBuffer = ByteBuffer.wrap(bytes).order(byteOrder).asShortBuffer();
+        short[] shorts = new short[shortBuffer.remaining()];
+        shortBuffer.get(shorts);
+        return shorts;
+    }
+
+    /** @return The bytes. */
+    public byte[] getBytes() {
+        return mBytes;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof Bytes)) {
+            return false;
+        }
+        Bytes that = (Bytes) o;
+        return Arrays.equals(mBytes, that.mBytes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Arrays.hashCode(mBytes);
+    }
+
+    @Override
+    public String toString() {
+        return toHexString(mBytes);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ConnectException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ConnectException.java
new file mode 100644
index 0000000..9c8d292
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ConnectException.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ConnectErrorCode;
+
+
+/** Thrown when connecting to a bluetooth device fails. */
+public class ConnectException extends PairingException {
+    final @ConnectErrorCode int mErrorCode;
+
+    ConnectException(@ConnectErrorCode int errorCode, String format, Object... objects) {
+        super(format, objects);
+        this.mErrorCode = errorCode;
+    }
+
+    /** Returns error code. */
+    public @ConnectErrorCode int getErrorCode() {
+        return mErrorCode;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Constants.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Constants.java
new file mode 100644
index 0000000..cfecd2f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Constants.java
@@ -0,0 +1,703 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static android.bluetooth.BluetoothProfile.A2DP;
+import static android.bluetooth.BluetoothProfile.HEADSET;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.to128BitUuid;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.toFastPair128BitUuid;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import android.bluetooth.BluetoothA2dp;
+import android.bluetooth.BluetoothHeadset;
+import android.util.Log;
+
+import androidx.annotation.IntDef;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Shorts;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.util.Random;
+import java.util.UUID;
+
+/**
+ * Fast Pair and Transport Discovery Service constants.
+ *
+ * <p>Unless otherwise specified, these numbers come from
+ * {https://www.bluetooth.com/specifications/gatt}.
+ */
+public final class Constants {
+
+    /** A2DP sink service uuid. */
+    public static final short A2DP_SINK_SERVICE_UUID = 0x110B;
+
+    /** Headset service uuid. */
+    public static final short HEADSET_SERVICE_UUID = 0x1108;
+
+    /** Hands free sink service uuid. */
+    public static final short HANDS_FREE_SERVICE_UUID = 0x111E;
+
+    /** Bluetooth address length. */
+    public static final int BLUETOOTH_ADDRESS_LENGTH = 6;
+
+    private static final String TAG = Constants.class.getSimpleName();
+
+    /**
+     * Defined by https://developers.google.com/nearby/fast-pair/spec.
+     */
+    public static final class FastPairService {
+
+        /** Fast Pair service UUID. */
+        public static final UUID ID = to128BitUuid((short) 0xFE2C);
+
+        /**
+         * Characteristic to write verification bytes to during the key handshake.
+         */
+        public static final class KeyBasedPairingCharacteristic {
+
+            private static final short SHORT_UUID = 0x1234;
+
+            /**
+             * Gets the new 128-bit UUID of this characteristic.
+             *
+             * <p>Note: For GATT server only. GATT client should use {@link
+             * KeyBasedPairingCharacteristic#getId(BluetoothGattConnection)}.
+             */
+            public static final UUID CUSTOM_128_BIT_UUID = toFastPair128BitUuid(SHORT_UUID);
+
+            /**
+             * Gets the {@link UUID} of this characteristic.
+             *
+             * <p>This method is designed for being backward compatible with old version of UUID
+             * therefore needs the {@link BluetoothGattConnection} parameter to check the supported
+             * status of the Fast Pair provider.
+             */
+            public static UUID getId(BluetoothGattConnection gattConnection) {
+                return getSupportedUuid(gattConnection, SHORT_UUID);
+            }
+
+            /**
+             * Constants related to the decrypted request written to this characteristic.
+             */
+            public static final class Request {
+
+                /**
+                 * The size of this message.
+                 */
+                public static final int SIZE = 16;
+
+                /**
+                 * The index of this message for indicating the type byte.
+                 */
+                public static final int TYPE_INDEX = 0;
+
+                /**
+                 * The index of this message for indicating the flags byte.
+                 */
+                public static final int FLAGS_INDEX = 1;
+
+                /**
+                 * The index of this message for indicating the verification data start from.
+                 */
+                public static final int VERIFICATION_DATA_INDEX = 2;
+
+                /**
+                 * The length of verification data, it is Provider’s current BLE address or public
+                 * address.
+                 */
+                public static final int VERIFICATION_DATA_LENGTH = BLUETOOTH_ADDRESS_LENGTH;
+
+                /**
+                 * The index of this message for indicating the seeker's public address start from.
+                 */
+                public static final int SEEKER_PUBLIC_ADDRESS_INDEX = 8;
+
+                /**
+                 * The index of this message for indicating event group.
+                 */
+                public static final int EVENT_GROUP_INDEX = 8;
+
+                /**
+                 * The index of this message for indicating event code.
+                 */
+                public static final int EVENT_CODE_INDEX = 9;
+
+                /**
+                 * The index of this message for indicating the length of additional data of the
+                 * event.
+                 */
+                public static final int EVENT_ADDITIONAL_DATA_LENGTH_INDEX = 10;
+
+                /**
+                 * The index of this message for indicating the event additional data start from.
+                 */
+                public static final int EVENT_ADDITIONAL_DATA_INDEX = 11;
+
+                /**
+                 * The index of this message for indicating the additional data type used in the
+                 * following Additional Data characteristic.
+                 */
+                public static final int ADDITIONAL_DATA_TYPE_INDEX = 10;
+
+                /**
+                 * The type of this message for Key-based Pairing Request.
+                 */
+                public static final byte TYPE_KEY_BASED_PAIRING_REQUEST = 0x00;
+
+                /**
+                 * The bit indicating that the Fast Pair device should temporarily become
+                 * discoverable.
+                 */
+                public static final byte REQUEST_DISCOVERABLE = (byte) (1 << 7);
+
+                /**
+                 * The bit indicating that the requester (Seeker) has included their public address
+                 * in bytes [7,12] of the request, and the Provider should initiate bonding to that
+                 * address.
+                 */
+                public static final byte PROVIDER_INITIATES_BONDING = (byte) (1 << 6);
+
+                /**
+                 * The bit indicating that Seeker requests Provider shall return the existing name.
+                 */
+                public static final byte REQUEST_DEVICE_NAME = (byte) (1 << 5);
+
+                /**
+                 * The bit to request retroactive pairing.
+                 */
+                public static final byte REQUEST_RETROACTIVE_PAIR = (byte) (1 << 4);
+
+                /**
+                 * The type of this message for action over BLE.
+                 */
+                public static final byte TYPE_ACTION_OVER_BLE = 0x10;
+
+                private Request() {
+                }
+            }
+
+            /**
+             * Enumerates all flags of key-based pairing request.
+             */
+            @Retention(RetentionPolicy.SOURCE)
+            @IntDef(
+                    value = {
+                            KeyBasedPairingRequestFlag.REQUEST_DISCOVERABLE,
+                            KeyBasedPairingRequestFlag.PROVIDER_INITIATES_BONDING,
+                            KeyBasedPairingRequestFlag.REQUEST_DEVICE_NAME,
+                            KeyBasedPairingRequestFlag.REQUEST_RETROACTIVE_PAIR,
+                    })
+            public @interface KeyBasedPairingRequestFlag {
+                /**
+                 * The bit indicating that the Fast Pair device should temporarily become
+                 * discoverable.
+                 */
+                int REQUEST_DISCOVERABLE = (byte) (1 << 7);
+                /**
+                 * The bit indicating that the requester (Seeker) has included their public address
+                 * in bytes [7,12] of the request, and the Provider should initiate bonding to that
+                 * address.
+                 */
+                int PROVIDER_INITIATES_BONDING = (byte) (1 << 6);
+                /**
+                 * The bit indicating that Seeker requests Provider shall return the existing name.
+                 */
+                int REQUEST_DEVICE_NAME = (byte) (1 << 5);
+                /**
+                 * The bit indicating that the Seeker request retroactive pairing.
+                 */
+                int REQUEST_RETROACTIVE_PAIR = (byte) (1 << 4);
+            }
+
+            /**
+             * Enumerates all flags of action over BLE request, see Fast Pair spec for details.
+             */
+            @IntDef(
+                    value = {
+                            ActionOverBleFlag.DEVICE_ACTION,
+                            ActionOverBleFlag.ADDITIONAL_DATA_CHARACTERISTIC,
+                    })
+            public @interface ActionOverBleFlag {
+                /**
+                 * The bit indicating that the handshaking is for Device Action.
+                 */
+                int DEVICE_ACTION = (byte) (1 << 7);
+                /**
+                 * The bit indicating that this handshake will be followed by Additional Data
+                 * characteristic.
+                 */
+                int ADDITIONAL_DATA_CHARACTERISTIC = (byte) (1 << 6);
+            }
+
+
+            /**
+             * Constants related to the decrypted response sent back in a notify.
+             */
+            public static final class Response {
+
+                /**
+                 * The type of this message = Key-based Pairing Response.
+                 */
+                public static final byte TYPE = 0x01;
+
+                private Response() {
+                }
+            }
+
+            private KeyBasedPairingCharacteristic() {
+            }
+        }
+
+        /**
+         * Characteristic used during Key-based Pairing, to exchange the encrypted passkey.
+         */
+        public static final class PasskeyCharacteristic {
+
+            private static final short SHORT_UUID = 0x1235;
+
+            /**
+             * Gets the new 128-bit UUID of this characteristic.
+             *
+             * <p>Note: For GATT server only. GATT client should use {@link
+             * PasskeyCharacteristic#getId(BluetoothGattConnection)}.
+             */
+            public static final UUID CUSTOM_128_BIT_UUID = toFastPair128BitUuid(SHORT_UUID);
+
+            /**
+             * Gets the {@link UUID} of this characteristic.
+             *
+             * <p>This method is designed for being backward compatible with old version of UUID
+             * therefore
+             * needs the {@link BluetoothGattConnection} parameter to check the supported status of
+             * the Fast Pair provider.
+             */
+            public static UUID getId(BluetoothGattConnection gattConnection) {
+                return getSupportedUuid(gattConnection, SHORT_UUID);
+            }
+
+            /**
+             * The type of the Passkey Block message.
+             */
+            @IntDef(
+                    value = {
+                            Type.SEEKER,
+                            Type.PROVIDER,
+                    })
+            public @interface Type {
+                /**
+                 * Seeker's Passkey.
+                 */
+                int SEEKER = (byte) 0x02;
+                /**
+                 * Provider's Passkey.
+                 */
+                int PROVIDER = (byte) 0x03;
+            }
+
+            /**
+             * Constructs the encrypted value to write to the characteristic.
+             */
+            public static byte[] encrypt(@Type int type, byte[] secret, int passkey)
+                    throws GeneralSecurityException {
+                Preconditions.checkArgument(
+                        0 < passkey && passkey < /*2^24=*/ 16777216,
+                        "Passkey %s must be positive and fit in 3 bytes",
+                        passkey);
+                byte[] passkeyBytes =
+                        new byte[]{(byte) (passkey >>> 16), (byte) (passkey >>> 8), (byte) passkey};
+                byte[] salt =
+                        new byte[AesEcbSingleBlockEncryption.AES_BLOCK_LENGTH - 1
+                                - passkeyBytes.length];
+                new Random().nextBytes(salt);
+                return AesEcbSingleBlockEncryption.encrypt(
+                        secret, concat(new byte[]{(byte) type}, passkeyBytes, salt));
+            }
+
+            /**
+             * Extracts the passkey from the encrypted characteristic value.
+             */
+            public static int decrypt(@Type int type, byte[] secret,
+                    byte[] passkeyCharacteristicValue)
+                    throws GeneralSecurityException {
+                byte[] decrypted = AesEcbSingleBlockEncryption
+                        .decrypt(secret, passkeyCharacteristicValue);
+                if (decrypted[0] != (byte) type) {
+                    throw new GeneralSecurityException(
+                            "Wrong Passkey Block type (expected " + type + ", got "
+                                    + decrypted[0] + ")");
+                }
+                return ByteBuffer.allocate(4)
+                        .put((byte) 0)
+                        .put(decrypted, /*offset=*/ 1, /*length=*/ 3)
+                        .getInt(0);
+            }
+
+            private PasskeyCharacteristic() {
+            }
+        }
+
+        /**
+         * Characteristic to write to during the key exchange.
+         */
+        public static final class AccountKeyCharacteristic {
+
+            private static final short SHORT_UUID = 0x1236;
+
+            /**
+             * Gets the new 128-bit UUID of this characteristic.
+             *
+             * <p>Note: For GATT server only. GATT client should use {@link
+             * AccountKeyCharacteristic#getId(BluetoothGattConnection)}.
+             */
+            public static final UUID CUSTOM_128_BIT_UUID = toFastPair128BitUuid(SHORT_UUID);
+
+            /**
+             * Gets the {@link UUID} of this characteristic.
+             *
+             * <p>This method is designed for being backward compatible with old version of UUID
+             * therefore
+             * needs the {@link BluetoothGattConnection} parameter to check the supported status of
+             * the Fast Pair provider.
+             */
+            public static UUID getId(BluetoothGattConnection gattConnection) {
+                return getSupportedUuid(gattConnection, SHORT_UUID);
+            }
+
+            /**
+             * The type for this message, account key request.
+             */
+            public static final byte TYPE = 0x04;
+
+            private AccountKeyCharacteristic() {
+            }
+        }
+
+        /**
+         * Characteristic to write to and notify on for handling personalized name, see {@link
+         * NamingEncoder}.
+         */
+        public static final class NameCharacteristic {
+
+            private static final short SHORT_UUID = 0x1237;
+
+            /**
+             * Gets the new 128-bit UUID of this characteristic.
+             *
+             * <p>Note: For GATT server only. GATT client should use {@link
+             * NameCharacteristic#getId(BluetoothGattConnection)}.
+             */
+            public static final UUID CUSTOM_128_BIT_UUID = toFastPair128BitUuid(SHORT_UUID);
+
+            /**
+             * Gets the {@link UUID} of this characteristic.
+             *
+             * <p>This method is designed for being backward compatible with old version of UUID
+             * therefore
+             * needs the {@link BluetoothGattConnection} parameter to check the supported status of
+             * the Fast Pair provider.
+             */
+            public static UUID getId(BluetoothGattConnection gattConnection) {
+                return getSupportedUuid(gattConnection, SHORT_UUID);
+            }
+
+            private NameCharacteristic() {
+            }
+        }
+
+        /**
+         * Characteristic to write to and notify on for handling additional data, see
+         * https://developers.google.com/nearby/fast-pair/early-access/spec#AdditionalData
+         */
+        public static final class AdditionalDataCharacteristic {
+
+            private static final short SHORT_UUID = 0x1237;
+
+            public static final int DATA_ID_INDEX = 0;
+            public static final int DATA_LENGTH_INDEX = 1;
+            public static final int DATA_START_INDEX = 2;
+
+            /**
+             * Gets the new 128-bit UUID of this characteristic.
+             *
+             * <p>Note: For GATT server only. GATT client should use {@link
+             * AdditionalDataCharacteristic#getId(BluetoothGattConnection)}.
+             */
+            public static final UUID CUSTOM_128_BIT_UUID = toFastPair128BitUuid(SHORT_UUID);
+
+            /**
+             * Gets the {@link UUID} of this characteristic.
+             *
+             * <p>This method is designed for being backward compatible with old version of UUID
+             * therefore
+             * needs the {@link BluetoothGattConnection} parameter to check the supported status of
+             * the Fast Pair provider.
+             */
+            public static UUID getId(BluetoothGattConnection gattConnection) {
+                return getSupportedUuid(gattConnection, SHORT_UUID);
+            }
+
+            /**
+             * Enumerates all types of additional data.
+             */
+            @Retention(RetentionPolicy.SOURCE)
+            @IntDef(
+                    value = {
+                            AdditionalDataType.PERSONALIZED_NAME,
+                            AdditionalDataType.UNKNOWN,
+                    })
+            public @interface AdditionalDataType {
+                /**
+                 * The value indicating that the type is for personalized name.
+                 */
+                int PERSONALIZED_NAME = (byte) 0x01;
+                int UNKNOWN = (byte) 0x00; // and all others.
+            }
+        }
+
+        /**
+         * Characteristic to control the beaconing feature (FastPair+Eddystone).
+         */
+        public static final class BeaconActionsCharacteristic {
+
+            private static final short SHORT_UUID = 0x1238;
+
+            /**
+             * Gets the new 128-bit UUID of this characteristic.
+             *
+             * <p>Note: For GATT server only. GATT client should use {@link
+             * BeaconActionsCharacteristic#getId(BluetoothGattConnection)}.
+             */
+            public static final UUID CUSTOM_128_BIT_UUID = toFastPair128BitUuid(SHORT_UUID);
+
+            /**
+             * Gets the {@link UUID} of this characteristic.
+             *
+             * <p>This method is designed for being backward compatible with old version of UUID
+             * therefore
+             * needs the {@link BluetoothGattConnection} parameter to check the supported status of
+             * the Fast Pair provider.
+             */
+            public static UUID getId(BluetoothGattConnection gattConnection) {
+                return getSupportedUuid(gattConnection, SHORT_UUID);
+            }
+
+            /**
+             * Enumerates all types of beacon actions.
+             */
+            /** Fast Pair Bond State. */
+            @Retention(RetentionPolicy.SOURCE)
+            @IntDef(
+                    value = {
+                            BeaconActionType.READ_BEACON_PARAMETERS,
+                            BeaconActionType.READ_PROVISIONING_STATE,
+                            BeaconActionType.SET_EPHEMERAL_IDENTITY_KEY,
+                            BeaconActionType.CLEAR_EPHEMERAL_IDENTITY_KEY,
+                            BeaconActionType.READ_EPHEMERAL_IDENTITY_KEY,
+                            BeaconActionType.RING,
+                            BeaconActionType.READ_RINGING_STATE,
+                            BeaconActionType.UNKNOWN,
+                    })
+            public @interface BeaconActionType {
+                int READ_BEACON_PARAMETERS = (byte) 0x00;
+                int READ_PROVISIONING_STATE = (byte) 0x01;
+                int SET_EPHEMERAL_IDENTITY_KEY = (byte) 0x02;
+                int CLEAR_EPHEMERAL_IDENTITY_KEY = (byte) 0x03;
+                int READ_EPHEMERAL_IDENTITY_KEY = (byte) 0x04;
+                int RING = (byte) 0x05;
+                int READ_RINGING_STATE = (byte) 0x06;
+                int UNKNOWN = (byte) 0xFF; // and all others
+            }
+
+            /** Converts value to enum. */
+            public static @BeaconActionType int valueOf(byte value) {
+                switch(value) {
+                    case BeaconActionType.READ_BEACON_PARAMETERS:
+                    case BeaconActionType.READ_PROVISIONING_STATE:
+                    case BeaconActionType.SET_EPHEMERAL_IDENTITY_KEY:
+                    case BeaconActionType.CLEAR_EPHEMERAL_IDENTITY_KEY:
+                    case BeaconActionType.READ_EPHEMERAL_IDENTITY_KEY:
+                    case BeaconActionType.RING:
+                    case BeaconActionType.READ_RINGING_STATE:
+                    case BeaconActionType.UNKNOWN:
+                        return value;
+                    default:
+                        return BeaconActionType.UNKNOWN;
+                }
+            }
+        }
+
+
+        /**
+         * Characteristic to read for checking firmware version. 0X2A26 is assigned number from
+         * bluetooth SIG website.
+         */
+        public static final class FirmwareVersionCharacteristic {
+
+            /** UUID for firmware version. */
+            public static final UUID ID = to128BitUuid((short) 0x2A26);
+
+            private FirmwareVersionCharacteristic() {
+            }
+        }
+
+        private FastPairService() {
+        }
+    }
+
+    /**
+     * Defined by the BR/EDR Handover Profile. Pre-release version here:
+     * {https://jfarfel.users.x20web.corp.google.com/Bluetooth%20Handover%20d09.pdf}
+     */
+    public interface TransportDiscoveryService {
+
+        UUID ID = to128BitUuid((short) 0x1824);
+
+        byte BLUETOOTH_SIG_ORGANIZATION_ID = 0x01;
+        byte SERVICE_UUIDS_16_BIT_LIST_TYPE = 0x01;
+        byte SERVICE_UUIDS_32_BIT_LIST_TYPE = 0x02;
+        byte SERVICE_UUIDS_128_BIT_LIST_TYPE = 0x03;
+
+        /**
+         * Writing to this allows you to activate the BR/EDR transport.
+         */
+        interface ControlPointCharacteristic {
+
+            UUID ID = to128BitUuid((short) 0x2ABC);
+            byte ACTIVATE_TRANSPORT_OP_CODE = 0x01;
+        }
+
+        /**
+         * Info necessary to pair (mostly the Bluetooth Address).
+         */
+        interface BrHandoverDataCharacteristic {
+
+            UUID ID = to128BitUuid((short) 0x2C01);
+
+            /**
+             * All bits are reserved for future use.
+             */
+            byte BR_EDR_FEATURES = 0x00;
+        }
+
+        /**
+         * This characteristic exists only to wrap the descriptor.
+         */
+        interface BluetoothSigDataCharacteristic {
+
+            UUID ID = to128BitUuid((short) 0x2C02);
+
+            /**
+             * The entire Transport Block data (e.g. supported Bluetooth services).
+             */
+            interface BrTransportBlockDataDescriptor {
+
+                UUID ID = to128BitUuid((short) 0x2C03);
+            }
+        }
+    }
+
+    public static final UUID CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR_UUID =
+            to128BitUuid((short) 0x2902);
+
+    /**
+     * Wrapper for Bluetooth profile
+     */
+    public static class Profile {
+
+        public final int type;
+        public final String name;
+        public final String connectionStateAction;
+
+        private Profile(int type, String name, String connectionStateAction) {
+            this.type = type;
+            this.name = name;
+            this.connectionStateAction = connectionStateAction;
+        }
+
+        @Override
+        public String toString() {
+            return name;
+        }
+    }
+
+    /**
+     * {@link BluetoothHeadset} is used for both Headset and HandsFree (HFP).
+     */
+    private static final Profile HEADSET_AND_HANDS_FREE_PROFILE =
+            new Profile(
+                    HEADSET, "HEADSET_AND_HANDS_FREE",
+                    BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
+
+    /** Fast Pair supported profiles. */
+    public static final ImmutableMap<Short, Profile> PROFILES =
+            ImmutableMap.<Short, Profile>builder()
+                    .put(
+                            Constants.A2DP_SINK_SERVICE_UUID,
+                            new Profile(A2DP, "A2DP",
+                                    BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED))
+                    .put(Constants.HEADSET_SERVICE_UUID, HEADSET_AND_HANDS_FREE_PROFILE)
+                    .put(Constants.HANDS_FREE_SERVICE_UUID, HEADSET_AND_HANDS_FREE_PROFILE)
+                    .build();
+
+    static short[] getSupportedProfiles() {
+        return Shorts.toArray(PROFILES.keySet());
+    }
+
+    /**
+     * Helper method of getting 128-bit UUID for Fast Pair custom GATT characteristics.
+     *
+     * <p>This method is designed for being backward compatible with old version of UUID therefore
+     * needs the {@link BluetoothGattConnection} parameter to check the supported status of the Fast
+     * Pair provider.
+     *
+     * <p>Note: For new custom GATT characteristics, don't need to use this helper and please just
+     * call {@code toFastPair128BitUuid(shortUuid)} to get the UUID. Which also implies that callers
+     * don't need to provide {@link BluetoothGattConnection} to get the UUID anymore.
+     */
+    private static UUID getSupportedUuid(BluetoothGattConnection gattConnection, short shortUuid) {
+        // In worst case (new characteristic not found), this method's performance impact is about
+        // 6ms
+        // by using Pixel2 + JBL LIVE220. And the impact should be less and less along with more and
+        // more devices adopt the new characteristics.
+        try {
+            // Checks the new UUID first.
+            if (gattConnection
+                    .getCharacteristic(FastPairService.ID, toFastPair128BitUuid(shortUuid))
+                    != null) {
+                Log.d(TAG, "Uses new KeyBasedPairingCharacteristic.ID");
+                return toFastPair128BitUuid(shortUuid);
+            }
+        } catch (BluetoothException e) {
+            Log.d(TAG, "Uses old KeyBasedPairingCharacteristic.ID");
+        }
+        // Returns the old UUID for default.
+        return to128BitUuid(shortUuid);
+    }
+
+    private Constants() {
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/CreateBondException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/CreateBondException.java
new file mode 100644
index 0000000..d6aa3b2
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/CreateBondException.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.CreateBondErrorCode;
+
+/** Thrown when binding (pairing) with a bluetooth device fails. */
+public class CreateBondException extends PairingException {
+    final @CreateBondErrorCode int mErrorCode;
+    int mReason;
+
+    CreateBondException(@CreateBondErrorCode int errorCode, int reason, String format,
+            Object... objects) {
+        super(format, objects);
+        this.mErrorCode = errorCode;
+        this.mReason = reason;
+    }
+
+    /** Returns error code. */
+    public @CreateBondErrorCode int getErrorCode() {
+        return mErrorCode;
+    }
+
+    /** Returns reason. */
+    public int getReason() {
+        return mReason;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/DeviceIntentReceiver.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/DeviceIntentReceiver.java
new file mode 100644
index 0000000..5bcf10a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/DeviceIntentReceiver.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+/**
+ * Like {@link SimpleBroadcastReceiver}, but for intents about a certain {@link BluetoothDevice}.
+ */
+abstract class DeviceIntentReceiver extends SimpleBroadcastReceiver {
+
+    private static final String TAG = DeviceIntentReceiver.class.getSimpleName();
+
+    private final BluetoothDevice mDevice;
+
+    static DeviceIntentReceiver oneShotReceiver(
+            Context context, Preferences preferences, BluetoothDevice device, String... actions) {
+        return new DeviceIntentReceiver(context, preferences, device, actions) {
+            @Override
+            protected void onReceiveDeviceIntent(Intent intent) throws Exception {
+                close();
+            }
+        };
+    }
+
+    /**
+     * @param context The context to use to register / unregister the receiver.
+     * @param device The interesting device. We ignore intents about other devices.
+     * @param actions The actions to include in our intent filter.
+     */
+    protected DeviceIntentReceiver(
+            Context context, Preferences preferences, BluetoothDevice device, String... actions) {
+        super(context, preferences, actions);
+        this.mDevice = device;
+    }
+
+    /**
+     * Called with intents about the interesting device (see {@link #DeviceIntentReceiver}). Any
+     * exception thrown by this method will be delivered via {@link #await}.
+     */
+    protected abstract void onReceiveDeviceIntent(Intent intent) throws Exception;
+
+    // incompatible types in argument.
+    @Override
+    protected void onReceive(Intent intent) throws Exception {
+        BluetoothDevice intentDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+        if (mDevice == null || mDevice.equals(intentDevice)) {
+            onReceiveDeviceIntent(intent);
+        } else {
+            Log.v(TAG,
+                    "Ignoring intent for device=" + maskBluetoothAddress(intentDevice)
+                            + "(expected "
+                            + maskBluetoothAddress(mDevice) + ")");
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EllipticCurveDiffieHellmanExchange.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EllipticCurveDiffieHellmanExchange.java
new file mode 100644
index 0000000..dbcdf07
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EllipticCurveDiffieHellmanExchange.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import androidx.annotation.Nullable;
+
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.ECPrivateKeySpec;
+import java.security.spec.ECPublicKeySpec;
+import java.util.Arrays;
+
+import javax.crypto.KeyAgreement;
+
+/**
+ * Helper for generating keys based off of the Elliptic-Curve Diffie-Hellman algorithm (ECDH).
+ */
+public final class EllipticCurveDiffieHellmanExchange {
+
+    public static final int PUBLIC_KEY_LENGTH = 64;
+    static final int PRIVATE_KEY_LENGTH = 32;
+
+    private static final String[] PROVIDERS = {"GmsCore_OpenSSL", "AndroidOpenSSL", "SC", "BC"};
+
+    private static final String EC_ALGORITHM = "EC";
+
+    /**
+     * Also known as prime256v1 or NIST P-256.
+     */
+    private static final ECGenParameterSpec EC_GEN_PARAMS = new ECGenParameterSpec("secp256r1");
+
+    @Nullable
+    private final ECPublicKey mPublicKey;
+    private final ECPrivateKey mPrivateKey;
+
+    /**
+     * Creates a new EllipticCurveDiffieHellmanExchange object.
+     */
+    public static EllipticCurveDiffieHellmanExchange create() throws GeneralSecurityException {
+        KeyPair keyPair = generateKeyPair();
+        return new EllipticCurveDiffieHellmanExchange(
+                (ECPublicKey) keyPair.getPublic(), (ECPrivateKey) keyPair.getPrivate());
+    }
+
+    /**
+     * Creates a new EllipticCurveDiffieHellmanExchange object.
+     */
+    public static EllipticCurveDiffieHellmanExchange create(byte[] privateKey)
+            throws GeneralSecurityException {
+        ECPrivateKey ecPrivateKey = (ECPrivateKey) generatePrivateKey(privateKey);
+        return new EllipticCurveDiffieHellmanExchange(/*publicKey=*/ null, ecPrivateKey);
+    }
+
+    private EllipticCurveDiffieHellmanExchange(
+            @Nullable ECPublicKey publicKey, ECPrivateKey privateKey) {
+        this.mPublicKey = publicKey;
+        this.mPrivateKey = privateKey;
+    }
+
+    /**
+     * @param otherPublicKey Another party's public key. See {@link #getPublicKey()} for format.
+     * @return The shared secret. Given our public key (and its private key), the other party can
+     * generate the same secret. This is a key meant for symmetric encryption.
+     */
+    public byte[] generateSecret(byte[] otherPublicKey) throws GeneralSecurityException {
+        KeyAgreement agreement = keyAgreement();
+        agreement.init(mPrivateKey);
+        agreement.doPhase(generatePublicKey(otherPublicKey), /*lastPhase=*/ true);
+        byte[] secret = agreement.generateSecret();
+        // Headsets only support AES with 128-bit keys. So, hash the secret so that the entropy is
+        // high and then take only the first 128-bits.
+        secret = MessageDigest.getInstance("SHA-256").digest(secret);
+        return Arrays.copyOf(secret, 16);
+    }
+
+    /**
+     * Returns a public point W on the NIST P-256 elliptic curve. First 32 bytes are the X
+     * coordinate, next 32 bytes are the Y coordinate. Each coordinate is an unsigned big-endian
+     * integer.
+     */
+    public @Nullable byte[] getPublicKey() {
+        if (mPublicKey == null) {
+            return null;
+        }
+        ECPoint w = mPublicKey.getW();
+        // See getPrivateKey for why we're resizing.
+        byte[] x = resizeWithLeadingZeros(w.getAffineX().toByteArray(), 32);
+        byte[] y = resizeWithLeadingZeros(w.getAffineY().toByteArray(), 32);
+        return concat(x, y);
+    }
+
+    /**
+     * Returns a private value S, an unsigned big-endian integer.
+     */
+    public byte[] getPrivateKey() {
+        // Note that BigInteger.toByteArray() returns a signed representation, so it will add an
+        // extra zero byte to the front if the first bit is 1.
+        // We must remove that leading zero (we know the number is unsigned). We must also add
+        // leading zeros if the number is too small.
+        return resizeWithLeadingZeros(mPrivateKey.getS().toByteArray(), 32);
+    }
+
+    /**
+     * Removes or adds leading zeros until we have an array of size {@code n}.
+     */
+    private static byte[] resizeWithLeadingZeros(byte[] x, int n) {
+        if (n < x.length) {
+            int start = x.length - n;
+            for (int i = 0; i < start; i++) {
+                if (x[i] != 0) {
+                    throw new IllegalArgumentException(
+                            "More than " + n + " non-zero bytes in " + Arrays.toString(x));
+                }
+            }
+            return Arrays.copyOfRange(x, start, x.length);
+        }
+        return concat(new byte[n - x.length], x);
+    }
+
+    /**
+     * @param publicKey See {@link #getPublicKey()} for format.
+     */
+    private static PublicKey generatePublicKey(byte[] publicKey) throws GeneralSecurityException {
+        if (publicKey.length != PUBLIC_KEY_LENGTH) {
+            throw new GeneralSecurityException("Public key length incorrect: " + publicKey.length);
+        }
+        byte[] x = Arrays.copyOf(publicKey, publicKey.length / 2);
+        byte[] y = Arrays.copyOfRange(publicKey, publicKey.length / 2, publicKey.length);
+        return keyFactory()
+                .generatePublic(
+                        new ECPublicKeySpec(
+                                new ECPoint(new BigInteger(/*signum=*/ 1, x),
+                                        new BigInteger(/*signum=*/ 1, y)),
+                                ecParameterSpec()));
+    }
+
+    /**
+     * @param privateKey See {@link #getPrivateKey()} for format.
+     */
+    private static PrivateKey generatePrivateKey(byte[] privateKey)
+            throws GeneralSecurityException {
+        if (privateKey.length != PRIVATE_KEY_LENGTH) {
+            throw new GeneralSecurityException("Private key length incorrect: "
+                    + privateKey.length);
+        }
+        return keyFactory()
+                .generatePrivate(
+                        new ECPrivateKeySpec(new BigInteger(/*signum=*/ 1, privateKey),
+                                ecParameterSpec()));
+    }
+
+    private static ECParameterSpec ecParameterSpec() throws GeneralSecurityException {
+        // This seems to be the simplest way to get the curve's ECParameterSpec. Verified that it's
+        // the same whether you get it from the public or private key, and that it's the same as the
+        // raw params in SecAggEcUtil.getNistP256Params().
+        return ((ECPublicKey) generateKeyPair().getPublic()).getParams();
+    }
+
+    private static KeyPair generateKeyPair() throws GeneralSecurityException {
+        KeyPairGenerator generator = findProvider(p -> KeyPairGenerator.getInstance(EC_ALGORITHM,
+                p));
+        generator.initialize(EC_GEN_PARAMS);
+        return generator.generateKeyPair();
+    }
+
+    private static KeyAgreement keyAgreement() throws NoSuchProviderException {
+        return findProvider(p -> KeyAgreement.getInstance("ECDH", p));
+    }
+
+    private static KeyFactory keyFactory() throws NoSuchProviderException {
+        return findProvider(p -> KeyFactory.getInstance(EC_ALGORITHM, p));
+    }
+
+    private interface ProviderConsumer<T> {
+
+        T tryProvider(String provider) throws NoSuchAlgorithmException, NoSuchProviderException;
+    }
+
+    private static <T> T findProvider(ProviderConsumer<T> providerConsumer)
+            throws NoSuchProviderException {
+        for (String provider : PROVIDERS) {
+            try {
+                return providerConsumer.tryProvider(provider);
+            } catch (NoSuchAlgorithmException | NoSuchProviderException e) {
+                // No-op
+            }
+        }
+        throw new NoSuchProviderException();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Event.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Event.java
new file mode 100644
index 0000000..0b50dfd
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Event.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.bluetooth.BluetoothDevice;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
+
+import java.util.Objects;
+
+import javax.annotation.Nullable;
+
+/**
+ * Describes events that are happening during fast pairing. EventCode is required, everything else
+ * is optional.
+ */
+public class Event implements Parcelable {
+
+    private final @EventCode int mEventCode;
+    private final long mTimestamp;
+    private final Short mProfile;
+    private final BluetoothDevice mBluetoothDevice;
+    private final Exception mException;
+
+    private Event(@EventCode int eventCode, long timestamp, @Nullable Short profile,
+            @Nullable BluetoothDevice bluetoothDevice, @Nullable Exception exception) {
+        mEventCode = eventCode;
+        mTimestamp = timestamp;
+        mProfile = profile;
+        mBluetoothDevice = bluetoothDevice;
+        mException = exception;
+    }
+
+    /**
+     * Returns event code.
+     */
+    public @EventCode int getEventCode() {
+        return mEventCode;
+    }
+
+    /**
+     * Returns timestamp.
+     */
+    public long getTimestamp() {
+        return mTimestamp;
+    }
+
+    /**
+     * Returns profile.
+     */
+    @Nullable
+    public Short getProfile() {
+        return mProfile;
+    }
+
+    /**
+     * Returns Bluetooth device.
+     */
+    @Nullable
+    public BluetoothDevice getBluetoothDevice() {
+        return mBluetoothDevice;
+    }
+
+    /**
+     * Returns exception.
+     */
+    @Nullable
+    public Exception getException() {
+        return mException;
+    }
+
+    /**
+     * Returns whether profile is not null.
+     */
+    public boolean hasProfile() {
+        return getProfile() != null;
+    }
+
+    /**
+     * Returns whether Bluetooth device is not null.
+     */
+    public boolean hasBluetoothDevice() {
+        return getBluetoothDevice() != null;
+    }
+
+    /**
+     * Returns a builder.
+     */
+    public static Builder builder() {
+        return new Event.Builder();
+    }
+
+    /**
+     * Returns whether it fails.
+     */
+    public boolean isFailure() {
+        return getException() != null;
+    }
+
+    @Override
+    public String toString() {
+        return "Event{"
+                + "eventCode=" + mEventCode + ", "
+                + "timestamp=" + mTimestamp + ", "
+                + "profile=" + mProfile + ", "
+                + "bluetoothDevice=" + mBluetoothDevice + ", "
+                + "exception=" + mException
+                + "}";
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (o instanceof Event) {
+            Event that = (Event) o;
+            return this.mEventCode == that.getEventCode()
+                    && this.mTimestamp == that.getTimestamp()
+                    && (this.mProfile == null
+                        ? that.getProfile() == null : this.mProfile.equals(that.getProfile()))
+                    && (this.mBluetoothDevice == null
+                        ? that.getBluetoothDevice() == null :
+                            this.mBluetoothDevice.equals(that.getBluetoothDevice()))
+                    && (this.mException == null
+                        ?  that.getException() == null :
+                            this.mException.equals(that.getException()));
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mEventCode, mTimestamp, mProfile, mBluetoothDevice, mException);
+    }
+
+
+    /**
+     * Builder
+     */
+    public static class Builder {
+        private @EventCode int mEventCode;
+        private long mTimestamp;
+        private Short mProfile;
+        private BluetoothDevice mBluetoothDevice;
+        private Exception mException;
+
+        /**
+         * Set event code.
+         */
+        public Builder setEventCode(@EventCode int eventCode) {
+            this.mEventCode = eventCode;
+            return this;
+        }
+
+        /**
+         * Set timestamp.
+         */
+        public Builder setTimestamp(long timestamp) {
+            this.mTimestamp = timestamp;
+            return this;
+        }
+
+        /**
+         * Set profile.
+         */
+        public Builder setProfile(@Nullable Short profile) {
+            this.mProfile = profile;
+            return this;
+        }
+
+        /**
+         * Set Bluetooth device.
+         */
+        public Builder setBluetoothDevice(@Nullable BluetoothDevice device) {
+            this.mBluetoothDevice = device;
+            return this;
+        }
+
+        /**
+         * Set exception.
+         */
+        public Builder setException(@Nullable Exception exception) {
+            this.mException = exception;
+            return this;
+        }
+
+        /**
+         * Builds event.
+         */
+        public Event build() {
+            return new Event(mEventCode, mTimestamp, mProfile, mBluetoothDevice, mException);
+        }
+    }
+
+    @Override
+    public final void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(getEventCode());
+        dest.writeLong(getTimestamp());
+        dest.writeValue(getProfile());
+        dest.writeParcelable(getBluetoothDevice(), 0);
+        dest.writeSerializable(getException());
+    }
+
+    @Override
+    public final int describeContents() {
+        return 0;
+    }
+
+    /**
+     * Event Creator instance.
+     */
+    public static final Creator<Event> CREATOR =
+            new Creator<Event>() {
+                @Override
+                /** Creates Event from Parcel. */
+                public Event createFromParcel(Parcel in) {
+                    return Event.builder()
+                            .setEventCode(in.readInt())
+                            .setTimestamp(in.readLong())
+                            .setProfile((Short) in.readValue(Short.class.getClassLoader()))
+                            .setBluetoothDevice(
+                                    in.readParcelable(BluetoothDevice.class.getClassLoader()))
+                            .setException((Exception) in.readSerializable())
+                            .build();
+                }
+
+                @Override
+                /** Returns Event array. */
+                public Event[] newArray(int size) {
+                    return new Event[size];
+                }
+            };
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EventLogger.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EventLogger.java
new file mode 100644
index 0000000..4fc1917
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EventLogger.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+/** Logs events triggered during Fast Pairing. */
+public interface EventLogger {
+
+    /** Log successful event. */
+    void logEventSucceeded(Event event);
+
+    /** Log failed event. */
+    void logEventFailed(Event event, Exception e);
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EventLoggerWrapper.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EventLoggerWrapper.java
new file mode 100644
index 0000000..024bfde
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/EventLoggerWrapper.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Preferences.ExtraLoggingInformation;
+import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
+
+import javax.annotation.Nullable;
+
+/**
+ * Convenience wrapper around EventLogger.
+ */
+// TODO(b/202559985): cleanup EventLoggerWrapper.
+class EventLoggerWrapper {
+
+    EventLoggerWrapper(@Nullable EventLogger eventLogger) {
+    }
+
+    /**
+     * Binds to the logging service. This operation blocks until binding has completed or timed
+     * out.
+     */
+    void bind(
+            Context context, String address,
+            @Nullable ExtraLoggingInformation extraLoggingInformation) {
+    }
+
+    boolean isBound() {
+        return false;
+    }
+
+    void unbind(Context context) {
+    }
+
+    void setCurrentEvent(@EventCode int code) {
+    }
+
+    void setCurrentProfile(short profile) {
+    }
+
+    void logCurrentEventFailed(Exception e) {
+    }
+
+    void logCurrentEventSucceeded() {
+    }
+
+    void setDevice(@Nullable BluetoothDevice device) {
+    }
+
+    boolean isCurrentEvent() {
+        return false;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConnection.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConnection.java
new file mode 100644
index 0000000..c963aa6
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConnection.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.annotation.WorkerThread;
+import android.bluetooth.BluetoothDevice;
+
+import androidx.annotation.Nullable;
+import androidx.core.util.Consumer;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/** Abstract class for pairing or connecting via FastPair. */
+public abstract class FastPairConnection {
+    @Nullable protected OnPairedCallback mPairedCallback;
+    @Nullable protected OnGetBluetoothAddressCallback mOnGetBluetoothAddressCallback;
+    @Nullable protected PasskeyConfirmationHandler mPasskeyConfirmationHandler;
+    @Nullable protected FastPairSignalChecker mFastPairSignalChecker;
+    @Nullable protected Consumer<Integer> mRescueFromError;
+    @Nullable protected Runnable mPrepareCreateBondCallback;
+    protected boolean mPasskeyIsGotten;
+
+    /** Sets a callback to be invoked once the device is paired. */
+    public void setOnPairedCallback(OnPairedCallback callback) {
+        this.mPairedCallback = callback;
+    }
+
+    /** Sets a callback to be invoked while the target bluetooth address is decided. */
+    public void setOnGetBluetoothAddressCallback(OnGetBluetoothAddressCallback callback) {
+        this.mOnGetBluetoothAddressCallback = callback;
+    }
+
+    /** Sets a callback to be invoked while handling the passkey confirmation. */
+    public void setPasskeyConfirmationHandler(
+            PasskeyConfirmationHandler passkeyConfirmationHandler) {
+        this.mPasskeyConfirmationHandler = passkeyConfirmationHandler;
+    }
+
+    public void setFastPairSignalChecker(FastPairSignalChecker fastPairSignalChecker) {
+        this.mFastPairSignalChecker = fastPairSignalChecker;
+    }
+
+    public void setRescueFromError(Consumer<Integer> rescueFromError) {
+        this.mRescueFromError = rescueFromError;
+    }
+
+    public void setPrepareCreateBondCallback(Runnable runnable) {
+        this.mPrepareCreateBondCallback = runnable;
+    }
+
+    @VisibleForTesting
+    @Nullable
+    public Runnable getPrepareCreateBondCallback() {
+        return mPrepareCreateBondCallback;
+    }
+
+    /**
+     * Sets the fast pair history for identifying whether or not the provider has paired with the
+     * primary account on other phones before.
+     */
+    @WorkerThread
+    public abstract void setFastPairHistory(List<FastPairHistoryItem> fastPairHistoryItem);
+
+    /** Sets the device name to the Provider. */
+    public abstract void setProviderDeviceName(String deviceName);
+
+    /** Gets the device name from the Provider. */
+    @Nullable
+    public abstract String getProviderDeviceName();
+
+    /**
+     * Gets the existing account key of the Provider.
+     *
+     * @return the existing account key if the Provider has paired with the account, null otherwise
+     */
+    @WorkerThread
+    @Nullable
+    public abstract byte[] getExistingAccountKey();
+
+    /**
+     * Pairs with Provider. Synchronous: Blocks until paired and connected. Throws on any error.
+     *
+     * @return the secret key for the user's account, if written
+     */
+    @WorkerThread
+    @Nullable
+    public abstract SharedSecret pair()
+            throws BluetoothException, InterruptedException, TimeoutException, ExecutionException,
+            PairingException, ReflectionException;
+
+    /**
+     * Pairs with Provider. Synchronous: Blocks until paired and connected. Throws on any error.
+     *
+     * @param key can be in two different formats. If it is 16 bytes long, then it is an AES account
+     *    key. Otherwise, it's a public key generated by {@link EllipticCurveDiffieHellmanExchange}.
+     *    See go/fast-pair-2-spec for how each of these keys are used.
+     * @return the secret key for the user's account, if written
+     */
+    @WorkerThread
+    @Nullable
+    public abstract SharedSecret pair(@Nullable byte[] key)
+            throws BluetoothException, InterruptedException, TimeoutException, ExecutionException,
+            PairingException, GeneralSecurityException, ReflectionException;
+
+    /** Unpairs with Provider. Synchronous: Blocks until unpaired. Throws on any error. */
+    @WorkerThread
+    public abstract void unpair(BluetoothDevice device)
+            throws InterruptedException, TimeoutException, ExecutionException, PairingException,
+            ReflectionException;
+
+    /** Gets the public address of the Provider. */
+    @Nullable
+    public abstract String getPublicAddress();
+
+
+    /** Callback for getting notifications when pairing has completed. */
+    public interface OnPairedCallback {
+        /** Called when the device at address has finished pairing. */
+        void onPaired(String address);
+    }
+
+    /** Callback for getting bluetooth address Bisto oobe need this information */
+    public interface OnGetBluetoothAddressCallback {
+        /** Called when the device has received bluetooth address. */
+        void onGetBluetoothAddress(String address);
+    }
+
+    /** Holds the exchanged secret key and the public mac address of the device. */
+    public static class SharedSecret {
+        private final byte[] mKey;
+        private final String mAddress;
+        private SharedSecret(byte[] key, String address) {
+            mKey = key;
+            mAddress = address;
+        }
+
+        /** Creates Shared Secret. */
+        public static SharedSecret create(byte[] key, String address) {
+            return new SharedSecret(key, address);
+        }
+
+        /** Gets Shared Secret Key. */
+        public byte[] getKey() {
+            return mKey;
+        }
+
+        /** Gets Shared Secret Address. */
+        public String getAddress() {
+            return mAddress;
+        }
+
+        @Override
+        public String toString() {
+            return "SharedSecret{"
+                    + "key=" + Arrays.toString(mKey) + ", "
+                    + "address=" + mAddress
+                    + "}";
+        }
+
+        @Override
+        public boolean equals(@Nullable Object o) {
+            if (o == this) {
+                return true;
+            }
+            if (o instanceof SharedSecret) {
+                SharedSecret that = (SharedSecret) o;
+                return Arrays.equals(this.mKey, that.getKey())
+                        && this.mAddress.equals(that.getAddress());
+            }
+            return false;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(Arrays.hashCode(mKey), mAddress);
+        }
+    }
+
+    /** Invokes if gotten the passkey. */
+    public void setPasskeyIsGotten() {
+        mPasskeyIsGotten = true;
+    }
+
+    /** Returns the value of passkeyIsGotten. */
+    public boolean getPasskeyIsGotten() {
+        return mPasskeyIsGotten;
+    }
+
+    /** Interface to get latest address of ModelId. */
+    public interface FastPairSignalChecker {
+        /** Gets address of ModelId. */
+        String getValidAddressForModelId(String currentDevice);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConstants.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConstants.java
new file mode 100644
index 0000000..0ff1bf2
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConstants.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.bluetooth.BluetoothDevice;
+
+/** Constants to share with other team. */
+public class FastPairConstants {
+    private static final String PACKAGE_NAME = "com.android.server.nearby";
+    private static final String PREFIX = PACKAGE_NAME + ".common.bluetooth.fastpair.";
+
+    /** MODEL_ID item name for extended intent field. */
+    public static final String EXTRA_MODEL_ID = PREFIX + "MODEL_ID";
+    /** CONNECTION_ID item name for extended intent field. */
+    public static final String EXTRA_CONNECTION_ID = PREFIX + "CONNECTION_ID";
+    /** BLUETOOTH_MAC_ADDRESS item name for extended intent field. */
+    public static final String EXTRA_BLUETOOTH_MAC_ADDRESS = PREFIX + "BLUETOOTH_MAC_ADDRESS";
+    /** COMPANION_SCAN_ITEM item name for extended intent field. */
+    public static final String EXTRA_SCAN_ITEM = PREFIX + "COMPANION_SCAN_ITEM";
+    /** BOND_RESULT item name for extended intent field. */
+    public static final String EXTRA_BOND_RESULT = PREFIX + "EXTRA_BOND_RESULT";
+
+    /**
+     * The bond result of the {@link BluetoothDevice} when FastPair launches the companion app, it
+     * means device is BONDED but the pairing process is not triggered by FastPair.
+     */
+    public static final int BOND_RESULT_SUCCESS_WITHOUT_FP = 0;
+
+    /**
+     * The bond result of the {@link BluetoothDevice} when FastPair launches the companion app, it
+     * means device is BONDED and the pairing process is triggered by FastPair.
+     */
+    public static final int BOND_RESULT_SUCCESS_WITH_FP = 1;
+
+    /**
+     * The bond result of the {@link BluetoothDevice} when FastPair launches the companion app, it
+     * means the pairing process triggered by FastPair is failed due to the lack of PIN code.
+     */
+    public static final int BOND_RESULT_FAIL_WITH_FP_WITHOUT_PIN = 2;
+
+    /**
+     * The bond result of the {@link BluetoothDevice} when FastPair launches the companion app, it
+     * means the pairing process triggered by FastPair is failed due to the PIN code is not
+     * confirmed by the user.
+     */
+    public static final int BOND_RESULT_FAIL_WITH_FP_WITH_PIN_NOT_CONFIRMED = 3;
+
+    /**
+     * The bond result of the {@link BluetoothDevice} when FastPair launches the companion app, it
+     * means the pairing process triggered by FastPair is failed due to the user thinks the PIN is
+     * wrong.
+     */
+    public static final int BOND_RESULT_FAIL_WITH_FP_WITH_PIN_WRONG = 4;
+
+    /**
+     * The bond result of the {@link BluetoothDevice} when FastPair launches the companion app, it
+     * means the pairing process triggered by FastPair is failed even after the user confirmed the
+     * PIN code is correct.
+     */
+    public static final int BOND_RESULT_FAIL_WITH_FP_WITH_PIN_CORRECT = 5;
+
+    private FastPairConstants() {}
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java
new file mode 100644
index 0000000..789ef59
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java
@@ -0,0 +1,2127 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDING;
+import static android.bluetooth.BluetoothDevice.BOND_NONE;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.get16BitUuid;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.to128BitUuid;
+import static com.android.server.nearby.common.bluetooth.fastpair.Bytes.toBytes;
+import static com.android.server.nearby.common.bluetooth.fastpair.Bytes.toShorts;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Verify.verifyNotNull;
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.primitives.Bytes.concat;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.ParcelUuid;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.BluetoothTimeoutException;
+import com.android.server.nearby.common.bluetooth.fastpair.BluetoothAudioPairer.KeyBasedPairingInfo;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AccountKeyCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AdditionalDataCharacteristic.AdditionalDataType;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.FirmwareVersionCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.NameCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.ActionOverBle;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.HandshakeException;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.HandshakeMessage;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.KeyBasedPairingRequest;
+import com.android.server.nearby.common.bluetooth.fastpair.Ltv.ParseException;
+import com.android.server.nearby.common.bluetooth.fastpair.TimingLogger.ScopedTiming;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection.ChangeObserver;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.fastpair.FastPairController;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.BrEdrHandoverErrorCode;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ConnectErrorCode;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.CreateBondErrorCode;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ErrorCode;
+import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
+
+import com.google.common.base.Ascii;
+import com.google.common.base.Preconditions;
+import com.google.common.primitives.Shorts;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteOrder;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Supports Fast Pair pairing with certain Bluetooth headphones, Auto, etc.
+ *
+ * <p>Based on https://developers.google.com/nearby/fast-pair/spec, the pairing is constructed by
+ * both BLE and BREDR connections. Example state transitions for Fast Pair 2, ie a pairing key is
+ * included in the request (note: timeouts and retries are governed by flags, may change):
+ *
+ * <pre>
+ * {@code
+ *   Connect GATT
+ *     A) Success -> Handshake
+ *     B) Failure (3s timeout) -> Retry 2x -> end
+ *
+ *   Handshake
+ *     A) Generate a shared secret with the headset (either using anti-spoofing key or account key)
+ *       1) Account key is used directly as the key
+ *       2) Anti-spoofing key is used by combining out private key with the headset's public and
+ *          sending our public to the headset to combine with their private to generate a shared
+ *          key. Sending our public key to headset takes ~3s.
+ *     B) Write an encrypted packet to the headset containing their BLE address for verification
+ *        that both sides have the same key (headset decodes this packet and checks it against their
+ *        own address) (~250ms).
+ *     C) Receive a response from the headset containing their public address (~250ms).
+ *
+ *   Discovery (for devices < Oreo)
+ *     A) Success -> Create Bond
+ *     B) Failure (10s timeout) -> Sleep 1s, Retry 3x -> end
+ *
+ *   Connect to device
+ *     A) If already bonded
+ *       1) Attempt directly connecting to supported profiles (A2DP, etc)
+ *         a) Success -> Write Account Key
+ *         b) Failure (15s timeout, usually fails within a ~2s) -> Remove bond (~1s) -> Create bond
+ *     B) If not already bonded
+ *       1) Create bond
+ *         a) Success -> Connect profile
+ *         b) Failure (15s timeout) -> Retry 2x -> end
+ *       2) Connect profile
+ *         a) Success -> Write account key
+ *         b) Failure -> Retry -> end
+ *
+ *   Write account key
+ *     A) Callback that pairing succeeded
+ *     B) Disconnect GATT
+ *     C) Reconnect GATT for secure connection
+ *     D) Write account key (~3s)
+ * }
+ * </pre>
+ *
+ * The performance profiling result by {@link TimingLogger}:
+ *
+ * <pre>
+ *   FastPairDualConnection [Exclusive time] / [Total time] ([Timestamp])
+ *     Connect GATT #1 3054ms (0)
+ *     Handshake 32ms / 740ms (3054)
+ *       Generate key via ECDH 10ms (3054)
+ *       Add salt 1ms (3067)
+ *       Encrypt request 3ms (3068)
+ *       Write data to GATT 692ms (3097)
+ *       Wait response from GATT 0ms (3789)
+ *       Decrypt response 2ms (3789)
+ *     Get BR/EDR handover information via SDP 1ms (3795)
+ *     Pair device #1 6ms / 4887ms (3805)
+ *       Create bond 3965ms / 4881ms (3809)
+ *         Exchange passkey 587ms / 915ms (7124)
+ *           Encrypt passkey 6ms (7694)
+ *           Send passkey to remote 290ms (7700)
+ *           Wait for remote passkey 0ms (7993)
+ *           Decrypt passkey 18ms (7994)
+ *           Confirm the pairing: true 14ms (8025)
+ *         Close BondedReceiver 1ms (8688)
+ *     Connect: A2DP 19ms / 370ms (8701)
+ *       Wait connection 348ms / 349ms (8720)
+ *         Close ConnectedReceiver 1ms (9068)
+ *       Close profile: A2DP 2ms (9069)
+ *     Write account key 2ms / 789ms (9163)
+ *       Encrypt key 0ms (9164)
+ *       Write key via GATT #1 777ms / 783ms (9164)
+ *         Close GATT 6ms (9941)
+ *       Start CloudSyncing 2ms (9947)
+ *       Broadcast Validator 2ms (9949)
+ *   FastPairDualConnection end, 9952ms
+ * </pre>
+ */
+// TODO(b/203441105): break down FastPairDualConnection into smaller classes.
+public class FastPairDualConnection extends FastPairConnection {
+
+    private static final String TAG = FastPairDualConnection.class.getSimpleName();
+
+    @VisibleForTesting
+    static final int GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST = 10000;
+    @VisibleForTesting
+    static final int GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED = 20000;
+    @VisibleForTesting
+    static final int GATT_ERROR_CODE_USER_RETRY = 30000;
+    @VisibleForTesting
+    static final int GATT_ERROR_CODE_PAIR_WITH_SAME_MODEL_ID_COUNT = 40000;
+    @VisibleForTesting
+    static final int GATT_ERROR_CODE_TIMEOUT = 1000;
+
+    @Nullable
+    private static String sInitialConnectionFirmwareVersion;
+    private static final byte[] REQUESTED_SERVICES_LTV =
+            new Ltv(
+                    TransportDiscoveryService.SERVICE_UUIDS_16_BIT_LIST_TYPE,
+                    toBytes(
+                            ByteOrder.LITTLE_ENDIAN,
+                            Constants.A2DP_SINK_SERVICE_UUID,
+                            Constants.HANDS_FREE_SERVICE_UUID,
+                            Constants.HEADSET_SERVICE_UUID))
+                    .getBytes();
+    private static final byte[] TDS_CONTROL_POINT_REQUEST =
+            concat(
+                    new byte[]{
+                            TransportDiscoveryService.ControlPointCharacteristic
+                                    .ACTIVATE_TRANSPORT_OP_CODE,
+                            TransportDiscoveryService.BLUETOOTH_SIG_ORGANIZATION_ID
+                    },
+                    REQUESTED_SERVICES_LTV);
+
+    private static boolean sTestMode = false;
+
+    static void enableTestMode() {
+        sTestMode = true;
+    }
+
+    /**
+     * Operation Result Code.
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    ResultCode.UNKNOWN,
+                    ResultCode.SUCCESS,
+                    ResultCode.OP_CODE_NOT_SUPPORTED,
+                    ResultCode.INVALID_PARAMETER,
+                    ResultCode.UNSUPPORTED_ORGANIZATION_ID,
+                    ResultCode.OPERATION_FAILED,
+            })
+
+    public @interface ResultCode {
+
+        int UNKNOWN = (byte) 0xFF;
+        int SUCCESS = (byte) 0x00;
+        int OP_CODE_NOT_SUPPORTED = (byte) 0x01;
+        int INVALID_PARAMETER = (byte) 0x02;
+        int UNSUPPORTED_ORGANIZATION_ID = (byte) 0x03;
+        int OPERATION_FAILED = (byte) 0x04;
+    }
+
+
+    private static @ResultCode int fromTdsControlPointIndication(byte[] response) {
+        return response == null || response.length < 2 ? ResultCode.UNKNOWN : from(response[1]);
+    }
+
+    private static @ResultCode int from(byte byteValue) {
+        switch (byteValue) {
+            case ResultCode.UNKNOWN:
+            case ResultCode.SUCCESS:
+            case ResultCode.OP_CODE_NOT_SUPPORTED:
+            case ResultCode.INVALID_PARAMETER:
+            case ResultCode.UNSUPPORTED_ORGANIZATION_ID:
+            case ResultCode.OPERATION_FAILED:
+                return byteValue;
+            default:
+                return ResultCode.UNKNOWN;
+        }
+    }
+
+    private static class BrEdrHandoverInformation {
+
+        private final byte[] mBluetoothAddress;
+        private final short[] mProfiles;
+
+        private BrEdrHandoverInformation(byte[] bluetoothAddress, short[] profiles) {
+            this.mBluetoothAddress = bluetoothAddress;
+
+            // For now, since we only connect to one profile, prefer A2DP Sink over headset/HFP.
+            // TODO(b/37167120): Connect to more than one profile.
+            Set<Short> profileSet = new HashSet<>(Shorts.asList(profiles));
+            if (profileSet.contains(Constants.A2DP_SINK_SERVICE_UUID)) {
+                profileSet.remove(Constants.HEADSET_SERVICE_UUID);
+                profileSet.remove(Constants.HANDS_FREE_SERVICE_UUID);
+            }
+            this.mProfiles = Shorts.toArray(profileSet);
+        }
+
+        @Override
+        public String toString() {
+            return "BrEdrHandoverInformation{"
+                    + maskBluetoothAddress(BluetoothAddress.encode(mBluetoothAddress))
+                    + ", profiles="
+                    + (mProfiles.length > 0 ? Shorts.join(",", mProfiles) : "(none)")
+                    + "}";
+        }
+    }
+
+    private final Context mContext;
+    private final Preferences mPreferences;
+    private final EventLoggerWrapper mEventLogger;
+    private final BluetoothAdapter mBluetoothAdapter =
+            checkNotNull(BluetoothAdapter.getDefaultAdapter());
+    private String mBleAddress;
+
+    private final TimingLogger mTimingLogger;
+    private GattConnectionManager mGattConnectionManager;
+    private boolean mProviderInitiatesBonding;
+    private @Nullable
+    byte[] mPairingSecret;
+    private @Nullable
+    byte[] mPairingKey;
+    @Nullable
+    private String mPublicAddress;
+    @VisibleForTesting
+    @Nullable
+    FastPairHistoryFinder mPairedHistoryFinder;
+    @Nullable
+    private String mProviderDeviceName = null;
+    private boolean mNeedUpdateProviderName = false;
+    @Nullable
+    DeviceNameReceiver mDeviceNameReceiver;
+    @Nullable
+    private HandshakeHandler mHandshakeHandlerForTest;
+    @Nullable
+    private Runnable mBeforeDirectlyConnectProfileFromCacheForTest;
+
+    public FastPairDualConnection(
+            Context context,
+            String bleAddress,
+            Preferences preferences,
+            @Nullable EventLogger eventLogger) {
+        this(context, bleAddress, preferences, eventLogger,
+                new TimingLogger("FastPairDualConnection", preferences));
+    }
+
+    @VisibleForTesting
+    FastPairDualConnection(
+            Context context,
+            String bleAddress,
+            Preferences preferences,
+            @Nullable EventLogger eventLogger,
+            TimingLogger timingLogger) {
+        this.mContext = context;
+        this.mPreferences = preferences;
+        this.mEventLogger = new EventLoggerWrapper(eventLogger);
+        this.mBleAddress = bleAddress;
+        this.mTimingLogger = timingLogger;
+    }
+
+    /**
+     * Unpairs with headphones. Synchronous: Blocks until unpaired. Throws on any error.
+     */
+    @WorkerThread
+    public void unpair(BluetoothDevice device)
+            throws ReflectionException, InterruptedException, ExecutionException, TimeoutException,
+            PairingException {
+        if (mPreferences.getExtraLoggingInformation() != null) {
+            mEventLogger
+                    .bind(mContext, device.getAddress(), mPreferences.getExtraLoggingInformation());
+        }
+        new BluetoothAudioPairer(
+                mContext,
+                device,
+                mPreferences,
+                mEventLogger,
+                /* keyBasedPairingInfo= */ null,
+                /* passkeyConfirmationHandler= */ null,
+                mTimingLogger)
+                .unpair();
+        if (mEventLogger.isBound()) {
+            mEventLogger.unbind(mContext);
+        }
+    }
+
+    /**
+     * Sets the fast pair history for identifying the provider which has paired (without being
+     * forgotten) with the primary account on the device, i.e. the history is not limited on this
+     * phone, can be on other phones with the same account. If they have already paired, Fast Pair
+     * should not generate new account key and default personalized name for it after initial pair.
+     */
+    @WorkerThread
+    public void setFastPairHistory(List<FastPairHistoryItem> fastPairHistoryItem) {
+        Log.i(TAG, "Paired history has been set.");
+        this.mPairedHistoryFinder = new FastPairHistoryFinder(fastPairHistoryItem);
+    }
+
+    /**
+     * Update the provider device name when we take provider default name and account based name
+     * into consideration.
+     */
+    public void setProviderDeviceName(String deviceName) {
+        Log.i(TAG, "Update provider device name = " + deviceName);
+        mProviderDeviceName = deviceName;
+        mNeedUpdateProviderName = true;
+    }
+
+    /**
+     * Gets the device name from the Provider (via GATT notify).
+     */
+    @Nullable
+    public String getProviderDeviceName() {
+        if (mDeviceNameReceiver == null) {
+            Log.i(TAG, "getProviderDeviceName failed, deviceNameReceiver == null.");
+            return null;
+        }
+        if (mPairingSecret == null) {
+            Log.i(TAG, "getProviderDeviceName failed, pairingSecret == null.");
+            return null;
+        }
+        String deviceName = mDeviceNameReceiver.getParsedResult(mPairingSecret);
+        Log.i(TAG, "getProviderDeviceName = " + deviceName);
+
+        return deviceName;
+    }
+
+    /**
+     * Get the existing account key of the provider, this API can be called after handshake.
+     *
+     * @return the existing account key if the provider has paired with the account before.
+     * Otherwise, return null, i.e. it is a real initial pairing.
+     */
+    @WorkerThread
+    @Nullable
+    public byte[] getExistingAccountKey() {
+        return mPairedHistoryFinder == null ? null : mPairedHistoryFinder.getExistingAccountKey();
+    }
+
+    /**
+     * Pairs with headphones. Synchronous: Blocks until paired and connected. Throws on any error.
+     *
+     * @return the secret key for the user's account, if written.
+     */
+    @WorkerThread
+    @Nullable
+    public SharedSecret pair()
+            throws BluetoothException, InterruptedException, ReflectionException, TimeoutException,
+            ExecutionException, PairingException {
+        try {
+            return pair(/*key=*/ null);
+        } catch (GeneralSecurityException e) {
+            throw new RuntimeException("Should never happen, no security key!", e);
+        }
+    }
+
+    /**
+     * Pairs with headphones. Synchronous: Blocks until paired and connected. Throws on any error.
+     *
+     * @param key can be in two different formats. If it is 16 bytes long, then it is an AES account
+     * key. Otherwise, it's a public key generated by {@link EllipticCurveDiffieHellmanExchange}.
+     * See go/fast-pair-2-spec for how each of these keys are used.
+     * @return the secret key for the user's account, if written
+     */
+    @WorkerThread
+    @Nullable
+    public SharedSecret pair(@Nullable byte[] key)
+            throws BluetoothException, InterruptedException, ReflectionException, TimeoutException,
+            ExecutionException, PairingException, GeneralSecurityException {
+        mPairingKey = key;
+        if (key != null) {
+            Log.i(TAG, "Starting to pair " + maskBluetoothAddress(mBleAddress) + ": key["
+                    + key.length + "], " + mPreferences);
+        } else {
+            Log.i(TAG, "Pairing " + maskBluetoothAddress(mBleAddress) + ": " + mPreferences);
+        }
+        if (mPreferences.getExtraLoggingInformation() != null) {
+            this.mEventLogger.bind(
+                    mContext, mBleAddress, mPreferences.getExtraLoggingInformation());
+        }
+        // Provider never initiates if key is null (Fast Pair 1.0).
+        if (key != null && mPreferences.getProviderInitiatesBondingIfSupported()) {
+            // Provider can't initiate if we can't get our own public address, so check.
+            this.mEventLogger.setCurrentEvent(EventCode.GET_LOCAL_PUBLIC_ADDRESS);
+            if (BluetoothAddress.getPublicAddress(mContext) != null) {
+                this.mEventLogger.logCurrentEventSucceeded();
+                mProviderInitiatesBonding = true;
+            } else {
+                this.mEventLogger
+                        .logCurrentEventFailed(new IllegalStateException("null bluetooth_address"));
+                Log.e(TAG,
+                        "Want provider to initiate bonding, but cannot access Bluetooth public "
+                                + "address. Falling back to initiating bonding ourselves.");
+            }
+        }
+
+        // User might be pairing with a bonded device. In this case, we just connect profile
+        // directly and finish pairing.
+        if (directConnectProfileWithCachedAddress()) {
+            callbackOnPaired();
+            mTimingLogger.dump();
+            if (mEventLogger.isBound()) {
+                mEventLogger.unbind(mContext);
+            }
+            return null;
+        }
+
+        // Lazily initialize a new connection manager for each pairing request.
+        initGattConnectionManager();
+        boolean isSecretHandshakeCompleted = true;
+
+        try {
+            if (key != null && key.length > 0) {
+                // GATT_CONNECTION_AND_SECRET_HANDSHAKE start.
+                mEventLogger.setCurrentEvent(EventCode.GATT_CONNECTION_AND_SECRET_HANDSHAKE);
+                isSecretHandshakeCompleted = false;
+                Exception lastException = null;
+                boolean lastExceptionFromHandshake = false;
+                long startTime = SystemClock.elapsedRealtime();
+                // We communicate over this connection twice for Key-based Pairing: once before
+                // bonding begins, and once during (to transfer the passkey). Empirically, keeping
+                // it alive throughout is far more reliable than disconnecting and reconnecting for
+                // each step. The while loop is for retry of GATT connection and handshake only.
+                do {
+                    boolean isHandshaking = false;
+                    try (BluetoothGattConnection connection =
+                            mGattConnectionManager
+                                    .getConnectionWithSignalLostCheck(mRescueFromError)) {
+                        mEventLogger.setCurrentEvent(EventCode.SECRET_HANDSHAKE);
+                        if (lastException != null && !lastExceptionFromHandshake) {
+                            logRetrySuccessEvent(EventCode.RECOVER_BY_RETRY_GATT, lastException,
+                                    mEventLogger);
+                            lastException = null;
+                        }
+                        try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                                "Handshake")) {
+                            isHandshaking = true;
+                            handshakeForKeyBasedPairing(key);
+                            // After handshake, Fast Pair has the public address of the provider, so
+                            // we can check if it has paired with the account.
+                            if (mPublicAddress != null && mPairedHistoryFinder != null) {
+                                if (mPairedHistoryFinder.isInPairedHistory(mPublicAddress)) {
+                                    Log.i(TAG, "The provider is found in paired history.");
+                                } else {
+                                    Log.i(TAG, "The provider is not found in paired history.");
+                                }
+                            }
+                        }
+                        isHandshaking = false;
+                        // SECRET_HANDSHAKE end.
+                        mEventLogger.logCurrentEventSucceeded();
+                        isSecretHandshakeCompleted = true;
+                        if (mPrepareCreateBondCallback != null) {
+                            mPrepareCreateBondCallback.run();
+                        }
+                        if (lastException != null && lastExceptionFromHandshake) {
+                            logRetrySuccessEvent(EventCode.RECOVER_BY_RETRY_HANDSHAKE_RECONNECT,
+                                    lastException, mEventLogger);
+                        }
+                        logManualRetryCounts(/* success= */ true);
+                        // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+                        mEventLogger.logCurrentEventSucceeded();
+                        return pair(mPreferences.getEnableBrEdrHandover());
+                    } catch (SignalLostException e) {
+                        long spentTime = SystemClock.elapsedRealtime() - startTime;
+                        if (spentTime > mPreferences.getAddressRotateRetryMaxSpentTimeMs()) {
+                            Log.w(TAG, "Signal lost but already spend too much time " + spentTime
+                                    + "ms");
+                            throw e;
+                        }
+
+                        logCurrentEventFailedBySignalLost(e);
+                        lastException = (Exception) e.getCause();
+                        lastExceptionFromHandshake = isHandshaking;
+                        if (mRescueFromError != null && isHandshaking) {
+                            mRescueFromError.accept(ErrorCode.SUCCESS_SECRET_HANDSHAKE_RECONNECT);
+                        }
+                        Log.i(TAG, "Signal lost, retry");
+                        // In case we meet some GATT error which is not recoverable and fail very
+                        // quick.
+                        SystemClock.sleep(mPreferences.getPairingRetryDelayMs());
+                    } catch (SignalRotatedException e) {
+                        long spentTime = SystemClock.elapsedRealtime() - startTime;
+                        if (spentTime > mPreferences.getAddressRotateRetryMaxSpentTimeMs()) {
+                            Log.w(TAG, "Address rotated but already spend too much time "
+                                    + spentTime + "ms");
+                            throw e;
+                        }
+
+                        logCurrentEventFailedBySignalRotated(e);
+                        setBleAddress(e.getNewAddress());
+                        lastException = (Exception) e.getCause();
+                        lastExceptionFromHandshake = isHandshaking;
+                        if (mRescueFromError != null) {
+                            mRescueFromError.accept(ErrorCode.SUCCESS_ADDRESS_ROTATE);
+                        }
+                        Log.i(TAG, "Address rotated, retry");
+                    } catch (HandshakeException e) {
+                        long spentTime = SystemClock.elapsedRealtime() - startTime;
+                        if (spentTime > mPreferences
+                                .getSecretHandshakeRetryGattConnectionMaxSpentTimeMs()) {
+                            Log.w(TAG, "Secret handshake failed but already spend too much time "
+                                    + spentTime + "ms");
+                            throw e.getOriginalException();
+                        }
+                        if (mEventLogger.isCurrentEvent()) {
+                            mEventLogger.logCurrentEventFailed(e.getOriginalException());
+                        }
+                        initGattConnectionManager();
+                        lastException = e.getOriginalException();
+                        lastExceptionFromHandshake = true;
+                        if (mRescueFromError != null) {
+                            mRescueFromError.accept(ErrorCode.SUCCESS_SECRET_HANDSHAKE_RECONNECT);
+                        }
+                        Log.i(TAG, "Handshake failed, retry GATT connection");
+                    }
+                } while (mPreferences.getRetryGattConnectionAndSecretHandshake());
+            }
+            if (mPrepareCreateBondCallback != null) {
+                mPrepareCreateBondCallback.run();
+            }
+            return pair(mPreferences.getEnableBrEdrHandover());
+        } catch (SignalLostException e) {
+            logCurrentEventFailedBySignalLost(e);
+            // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+            if (!isSecretHandshakeCompleted) {
+                logManualRetryCounts(/* success= */ false);
+                logCurrentEventFailedBySignalLost(e);
+            }
+            throw e;
+        } catch (SignalRotatedException e) {
+            logCurrentEventFailedBySignalRotated(e);
+            // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+            if (!isSecretHandshakeCompleted) {
+                logManualRetryCounts(/* success= */ false);
+                logCurrentEventFailedBySignalRotated(e);
+            }
+            throw e;
+        } catch (BluetoothException
+                | InterruptedException
+                | ReflectionException
+                | TimeoutException
+                | ExecutionException
+                | PairingException
+                | GeneralSecurityException e) {
+            if (mEventLogger.isCurrentEvent()) {
+                mEventLogger.logCurrentEventFailed(e);
+            }
+            // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+            if (!isSecretHandshakeCompleted) {
+                logManualRetryCounts(/* success= */ false);
+                if (mEventLogger.isCurrentEvent()) {
+                    mEventLogger.logCurrentEventFailed(e);
+                }
+            }
+            throw e;
+        } finally {
+            mTimingLogger.dump();
+            if (mEventLogger.isBound()) {
+                mEventLogger.unbind(mContext);
+            }
+        }
+    }
+
+    private boolean directConnectProfileWithCachedAddress() throws ReflectionException {
+        if (TextUtils.isEmpty(mPreferences.getCachedDeviceAddress())
+                || !mPreferences.getDirectConnectProfileIfModelIdInCache()
+                || mPreferences.getSkipConnectingProfiles()) {
+            return false;
+        }
+        Log.i(TAG, "Try to direct connect profile with cached address "
+                + maskBluetoothAddress(mPreferences.getCachedDeviceAddress()));
+        mEventLogger.setCurrentEvent(EventCode.DIRECTLY_CONNECT_PROFILE_WITH_CACHED_ADDRESS);
+        BluetoothDevice device =
+                mBluetoothAdapter.getRemoteDevice(mPreferences.getCachedDeviceAddress()).unwrap();
+        AtomicBoolean interruptConnection = new AtomicBoolean(false);
+        BroadcastReceiver receiver =
+                new BroadcastReceiver() {
+                    @Override
+                    public void onReceive(Context context, Intent intent) {
+                        if (intent == null
+                                || !BluetoothDevice.ACTION_PAIRING_REQUEST
+                                .equals(intent.getAction())) {
+                            return;
+                        }
+                        BluetoothDevice pairingDevice = intent
+                                .getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+                        if (pairingDevice == null || !device.getAddress()
+                                .equals(pairingDevice.getAddress())) {
+                            return;
+                        }
+                        abortBroadcast();
+                        // Should be the clear link key case, make it fail directly to go back to
+                        // initial pairing process.
+                        pairingDevice.setPairingConfirmation(/* confirm= */ false);
+                        Log.w(TAG, "Get pairing request broadcast for device "
+                                + maskBluetoothAddress(device.getAddress())
+                                + " while try to direct connect profile with cached address, reject"
+                                + " and to go back to initial pairing process");
+                        interruptConnection.set(true);
+                    }
+                };
+        mContext.registerReceiver(receiver,
+                new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST));
+        try (ScopedTiming scopedTiming =
+                new ScopedTiming(mTimingLogger,
+                        "Connect to profile with cached address directly")) {
+            if (mBeforeDirectlyConnectProfileFromCacheForTest != null) {
+                mBeforeDirectlyConnectProfileFromCacheForTest.run();
+            }
+            attemptConnectProfiles(
+                    new BluetoothAudioPairer(
+                            mContext,
+                            device,
+                            mPreferences,
+                            mEventLogger,
+                            /* keyBasedPairingInfo= */ null,
+                            /* passkeyConfirmationHandler= */ null,
+                            mTimingLogger),
+                    maskBluetoothAddress(device),
+                    getSupportedProfiles(device),
+                    /* numConnectionAttempts= */ 1,
+                    /* enablePairingBehavior= */ false,
+                    interruptConnection);
+            Log.i(TAG,
+                    "Directly connected to " + maskBluetoothAddress(device)
+                            + "with cached address.");
+            mEventLogger.logCurrentEventSucceeded();
+            mEventLogger.setDevice(device);
+            logPairWithPossibleCachedAddress(device.getAddress());
+            return true;
+        } catch (PairingException e) {
+            if (interruptConnection.get()) {
+                Log.w(TAG, "Fail to connected to " + maskBluetoothAddress(device)
+                        + " with cached address due to link key is cleared.", e);
+                mEventLogger.logCurrentEventFailed(
+                        new ConnectException(ConnectErrorCode.LINK_KEY_CLEARED,
+                                "Link key is cleared"));
+            } else {
+                Log.w(TAG, "Fail to connected to " + maskBluetoothAddress(device)
+                        + " with cached address.", e);
+                mEventLogger.logCurrentEventFailed(e);
+            }
+            return false;
+        } finally {
+            mContext.unregisterReceiver(receiver);
+        }
+    }
+
+    /**
+     * Logs for user retry, check go/fastpairquality21q3 for more details.
+     */
+    private void logManualRetryCounts(boolean success) {
+        if (!mPreferences.getLogUserManualRetry()) {
+            return;
+        }
+
+        // We don't want to be the final event on analytics.
+        if (!mEventLogger.isCurrentEvent()) {
+            return;
+        }
+
+        mEventLogger.setCurrentEvent(EventCode.GATT_HANDSHAKE_MANUAL_RETRY_ATTEMPTS);
+        if (mPreferences.getPairFailureCounts() <= 0 && success) {
+            mEventLogger.logCurrentEventSucceeded();
+        } else {
+            int errorCode = mPreferences.getPairFailureCounts();
+            if (errorCode > 99) {
+                errorCode = 99;
+            }
+            errorCode += success ? 0 : 100;
+            // To not conflict with current error codes.
+            errorCode += GATT_ERROR_CODE_USER_RETRY;
+            mEventLogger.logCurrentEventFailed(
+                    new BluetoothGattException("Error for manual retry", errorCode));
+        }
+    }
+
+    static void logRetrySuccessEvent(
+            @EventCode int eventCode,
+            @Nullable Exception recoverFromException,
+            EventLoggerWrapper eventLogger) {
+        if (recoverFromException == null) {
+            return;
+        }
+        eventLogger.setCurrentEvent(eventCode);
+        eventLogger.logCurrentEventFailed(recoverFromException);
+    }
+
+    private void initGattConnectionManager() {
+        mGattConnectionManager =
+                new GattConnectionManager(
+                        mContext,
+                        mPreferences,
+                        mEventLogger,
+                        mBluetoothAdapter,
+                        this::toggleBluetooth,
+                        mBleAddress,
+                        mTimingLogger,
+                        mFastPairSignalChecker,
+                        isPairingWithAntiSpoofingPublicKey());
+    }
+
+    private void logCurrentEventFailedBySignalRotated(SignalRotatedException e) {
+        if (!mEventLogger.isCurrentEvent()) {
+            return;
+        }
+
+        Log.w(TAG, "BLE Address for pairing device might rotated!");
+        mEventLogger.logCurrentEventFailed(
+                new BluetoothGattException(
+                        "BLE Address for pairing device might rotated",
+                        appendMoreErrorCode(GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED,
+                                e.getCause()),
+                        e));
+    }
+
+    private void logCurrentEventFailedBySignalLost(SignalLostException e) {
+        if (!mEventLogger.isCurrentEvent()) {
+            return;
+        }
+
+        Log.w(TAG, "BLE signal for pairing device might lost!");
+        mEventLogger.logCurrentEventFailed(
+                new BluetoothGattException(
+                        "BLE signal for pairing device might lost",
+                        appendMoreErrorCode(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST, e.getCause()),
+                        e));
+    }
+
+    @VisibleForTesting
+    static int appendMoreErrorCode(int masterErrorCode, @Nullable Throwable cause) {
+        if (cause instanceof BluetoothGattException) {
+            return masterErrorCode + ((BluetoothGattException) cause).getGattErrorCode();
+        } else if (cause instanceof TimeoutException
+                || cause instanceof BluetoothTimeoutException
+                || cause instanceof BluetoothOperationTimeoutException) {
+            return masterErrorCode + GATT_ERROR_CODE_TIMEOUT;
+        } else {
+            return masterErrorCode;
+        }
+    }
+
+    private void setBleAddress(String newAddress) {
+        if (TextUtils.isEmpty(newAddress) || Ascii.equalsIgnoreCase(newAddress, mBleAddress)) {
+            return;
+        }
+
+        mBleAddress = newAddress;
+
+        // Recreates a GattConnectionManager with the new address for establishing a new GATT
+        // connection later.
+        initGattConnectionManager();
+
+        mEventLogger.setDevice(mBluetoothAdapter.getRemoteDevice(mBleAddress).unwrap());
+    }
+
+    /**
+     * Gets the public address of the headset used in the connection. Before the handshake, this
+     * could be null.
+     */
+    @Nullable
+    public String getPublicAddress() {
+        return mPublicAddress;
+    }
+
+    /**
+     * Pairs with a Bluetooth device. In general, this process goes through the following steps:
+     *
+     * <ol>
+     *   <li>Get BrEdr handover information if requested
+     *   <li>Discover the device (on Android N and lower to work around a bug)
+     *   <li>Connect to the device
+     *       <ul>
+     *         <li>Attempt a direct connection to a supported profile if we're already bonded
+     *         <li>Create a new bond with the not bonded device and then connect to a supported
+     *             profile
+     *       </ul>
+     *   <li>Write the account secret
+     * </ol>
+     *
+     * <p>Blocks until paired. May take 10+ seconds, so run on a background thread.
+     */
+    @Nullable
+    private SharedSecret pair(boolean enableBrEdrHandover)
+            throws BluetoothException, InterruptedException, ReflectionException, TimeoutException,
+            ExecutionException, PairingException, GeneralSecurityException {
+        BrEdrHandoverInformation brEdrHandoverInformation = null;
+        if (enableBrEdrHandover) {
+            try (ScopedTiming scopedTiming =
+                    new ScopedTiming(mTimingLogger, "Get BR/EDR handover information via GATT")) {
+                brEdrHandoverInformation =
+                        getBrEdrHandoverInformation(mGattConnectionManager.getConnection());
+            } catch (BluetoothException | TdsException e) {
+                Log.w(TAG,
+                        "Couldn't get BR/EDR Handover info via TDS. Trying direct connect.", e);
+                mEventLogger.logCurrentEventFailed(e);
+            }
+        }
+
+        if (brEdrHandoverInformation == null) {
+            // Pair directly to the BLE address. Works if the BLE and Bluetooth Classic addresses
+            // are the same, or if we can do BLE cross-key transport.
+            brEdrHandoverInformation =
+                    new BrEdrHandoverInformation(
+                            BluetoothAddress
+                                    .decode(mPublicAddress != null ? mPublicAddress : mBleAddress),
+                            attemptGetBluetoothClassicProfiles(
+                                    mBluetoothAdapter.getRemoteDevice(mBleAddress).unwrap(),
+                                    mPreferences.getNumSdpAttempts()));
+        }
+
+        BluetoothDevice device =
+                mBluetoothAdapter.getRemoteDevice(brEdrHandoverInformation.mBluetoothAddress)
+                        .unwrap();
+        callbackOnGetAddress(device.getAddress());
+        mEventLogger.setDevice(device);
+
+        Log.i(TAG, "Pairing with " + brEdrHandoverInformation);
+        KeyBasedPairingInfo keyBasedPairingInfo =
+                mPairingSecret == null
+                        ? null
+                        : new KeyBasedPairingInfo(
+                                mPairingSecret, mGattConnectionManager, mProviderInitiatesBonding);
+
+        BluetoothAudioPairer pairer =
+                new BluetoothAudioPairer(
+                        mContext,
+                        device,
+                        mPreferences,
+                        mEventLogger,
+                        keyBasedPairingInfo,
+                        mPasskeyConfirmationHandler,
+                        mTimingLogger);
+
+        logPairWithPossibleCachedAddress(device.getAddress());
+        logPairWithModelIdInCacheAndDiscoveryFailForCachedAddress(device);
+
+        // In the case where we are already bonded, we should first just try connecting to supported
+        // profiles. If successful, then this will be much faster than recreating the bond like we
+        // normally do and we can finish early. It is also more reliable than tearing down the bond
+        // and recreating it.
+        try {
+            if (!sTestMode) {
+                attemptDirectConnectionIfBonded(device, pairer);
+            }
+            callbackOnPaired();
+            return maybeWriteAccountKey(device);
+        } catch (PairingException e) {
+            Log.i(TAG, "Failed to directly connect to supported profiles: " + e.getMessage());
+            // Catches exception when we fail to connect support profile. And makes the flow to go
+            // through step to write account key when device is bonded.
+            if (mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()
+                    && device.getBondState() == BluetoothDevice.BOND_BONDED) {
+                if (mPreferences.getSkipConnectingProfiles()
+                        && !mPreferences.getCheckBondStateWhenSkipConnectingProfiles()) {
+                    Log.i(TAG, "For notCheckBondStateWhenSkipConnectingProfiles case should do "
+                            + "re-bond");
+                } else {
+                    Log.i(TAG, "Fail to connect profile when device is bonded, still call back on"
+                            + "pair callback to show ui");
+                    callbackOnPaired();
+                    return maybeWriteAccountKey(device);
+                }
+            }
+        }
+
+        if (mPreferences.getMoreEventLogForQuality()) {
+            switch (device.getBondState()) {
+                case BOND_BONDED:
+                    mEventLogger.setCurrentEvent(EventCode.BEFORE_CREATE_BOND_BONDED);
+                    break;
+                case BOND_BONDING:
+                    mEventLogger.setCurrentEvent(EventCode.BEFORE_CREATE_BOND_BONDING);
+                    break;
+                case BOND_NONE:
+                default:
+                    mEventLogger.setCurrentEvent(EventCode.BEFORE_CREATE_BOND);
+            }
+        }
+
+        for (int i = 1; i <= mPreferences.getNumCreateBondAttempts(); i++) {
+            try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Pair device #" + i)) {
+                pairer.pair();
+                if (mPreferences.getMoreEventLogForQuality()) {
+                    // For EventCode.BEFORE_CREATE_BOND
+                    mEventLogger.logCurrentEventSucceeded();
+                }
+                break;
+            } catch (Exception e) {
+                mEventLogger.logCurrentEventFailed(e);
+                if (mPasskeyIsGotten) {
+                    Log.w(TAG,
+                            "createBond() failed because of " + e.getMessage()
+                                    + " after getting the passkey. Skip retry.");
+                    if (mPreferences.getMoreEventLogForQuality()) {
+                        // For EventCode.BEFORE_CREATE_BOND
+                        mEventLogger.logCurrentEventFailed(
+                                new CreateBondException(
+                                        CreateBondErrorCode.FAILED_BUT_ALREADY_RECEIVE_PASS_KEY,
+                                        0,
+                                        "Already get the passkey"));
+                    }
+                    break;
+                }
+                Log.e(TAG,
+                        "removeBond() or createBond() failed, attempt " + i + " of " + mPreferences
+                                .getNumCreateBondAttempts() + ". Bond state "
+                                + device.getBondState(), e);
+                if (i < mPreferences.getNumCreateBondAttempts()) {
+                    toggleBluetooth();
+
+                    // We've seen 3 createBond() failures within 100ms (!). And then success again
+                    // later (even without turning on/off bluetooth). So create some minimum break
+                    // time.
+                    Log.i(TAG, "Sleeping 1 sec after createBond() failure.");
+                    SystemClock.sleep(1000);
+                } else if (mPreferences.getMoreEventLogForQuality()) {
+                    // For EventCode.BEFORE_CREATE_BOND
+                    mEventLogger.logCurrentEventFailed(e);
+                }
+            }
+        }
+        boolean deviceCreateBondFailWithNullSecret = false;
+        if (!pairer.isPaired()) {
+            if (mPairingSecret != null) {
+                // Bonding could fail for a few different reasons here. It could be an error, an
+                // attacker may have tried to bond, or the device may not be up to spec.
+                throw new PairingException("createBond() failed, exiting connection process.");
+            } else if (mPreferences.getSkipConnectingProfiles()) {
+                throw new PairingException(
+                        "createBond() failed and skipping connecting to a profile.");
+            } else {
+                // When bond creation has failed, connecting a profile will still work most of the
+                // time for Fast Pair 1.0 devices (ie, pairing secret is null), so continue on with
+                // the spec anyways and attempt to connect supported profiles.
+                Log.w(TAG, "createBond() failed, will try connecting profiles anyway.");
+                deviceCreateBondFailWithNullSecret = true;
+            }
+        } else if (mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()) {
+            Log.i(TAG, "new flow to call on paired callback for ui when pairing step is finished");
+            callbackOnPaired();
+        }
+
+        if (!mPreferences.getSkipConnectingProfiles()) {
+            if (mPreferences.getWaitForUuidsAfterBonding()
+                    && brEdrHandoverInformation.mProfiles.length == 0) {
+                short[] supportedProfiles = getCachedUuids(device);
+                if (supportedProfiles.length == 0
+                        && mPreferences.getNumSdpAttemptsAfterBonded() > 0) {
+                    Log.i(TAG, "Found no supported profiles in UUID cache, manually trigger SDP.");
+                    attemptGetBluetoothClassicProfiles(device,
+                            mPreferences.getNumSdpAttemptsAfterBonded());
+                }
+                brEdrHandoverInformation =
+                        new BrEdrHandoverInformation(
+                                brEdrHandoverInformation.mBluetoothAddress, supportedProfiles);
+            }
+            short[] profiles = brEdrHandoverInformation.mProfiles;
+            if (profiles.length == 0) {
+                profiles = Constants.getSupportedProfiles();
+                Log.w(TAG,
+                        "Attempting to connect constants profiles, " + Arrays.toString(profiles));
+            } else {
+                Log.i(TAG, "Attempting to connect device profiles, " + Arrays.toString(profiles));
+            }
+
+            try {
+                attemptConnectProfiles(
+                        pairer,
+                        maskBluetoothAddress(device),
+                        profiles,
+                        mPreferences.getNumConnectAttempts(),
+                        /* enablePairingBehavior= */ false);
+            } catch (PairingException e) {
+                // For new pair flow to show ui, we already show success ui when finishing the
+                // createBond step. So we should catch the exception from connecting profile to
+                // avoid showing fail ui for user.
+                if (mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()
+                        && !deviceCreateBondFailWithNullSecret) {
+                    Log.i(TAG, "Fail to connect profile when device is bonded");
+                } else {
+                    throw e;
+                }
+            }
+        }
+        if (!mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()) {
+            Log.i(TAG, "original flow to call on paired callback for ui");
+            callbackOnPaired();
+        } else if (deviceCreateBondFailWithNullSecret) {
+            // This paired callback is called for device which create bond fail with null secret
+            // such as FastPair 1.0 device when directly connecting to any supported profile.
+            Log.i(TAG, "call on paired callback for ui for device with null secret without bonded "
+                    + "state");
+            callbackOnPaired();
+        }
+        if (mPreferences.getEnableFirmwareVersionCharacteristic()
+                && validateBluetoothGattCharacteristic(
+                mGattConnectionManager.getConnection(), FirmwareVersionCharacteristic.ID)) {
+            try {
+                sInitialConnectionFirmwareVersion = readFirmwareVersion();
+            } catch (BluetoothException e) {
+                Log.i(TAG, "Fast Pair: head phone does not support firmware read", e);
+            }
+        }
+
+        // Catch exception when writing account key or name fail to avoid showing pairing failure
+        // notice for user. Because device is already paired successfully based on paring step.
+        SharedSecret secret = null;
+        try {
+            secret = maybeWriteAccountKey(device);
+        } catch (InterruptedException
+                | ExecutionException
+                | TimeoutException
+                | NoSuchAlgorithmException
+                | BluetoothException e) {
+            Log.w(TAG, "Fast Pair: Got exception when writing account key or name to provider", e);
+        }
+
+        return secret;
+    }
+
+    private void logPairWithPossibleCachedAddress(String brEdrAddressForBonding) {
+        if (TextUtils.isEmpty(mPreferences.getPossibleCachedDeviceAddress())
+                || !mPreferences.getLogPairWithCachedModelId()) {
+            return;
+        }
+        mEventLogger.setCurrentEvent(EventCode.PAIR_WITH_CACHED_MODEL_ID);
+        if (Ascii.equalsIgnoreCase(
+                mPreferences.getPossibleCachedDeviceAddress(), brEdrAddressForBonding)) {
+            mEventLogger.logCurrentEventSucceeded();
+            Log.i(TAG, "Repair with possible cached device "
+                    + maskBluetoothAddress(mPreferences.getPossibleCachedDeviceAddress()));
+        } else {
+            mEventLogger.logCurrentEventFailed(
+                    new PairingException("Pairing with 2nd device with same model ID"));
+            Log.i(TAG, "Pair with a new device " + maskBluetoothAddress(brEdrAddressForBonding)
+                    + " with model ID in cache "
+                    + maskBluetoothAddress(mPreferences.getPossibleCachedDeviceAddress()));
+        }
+    }
+
+    /**
+     * Logs two type of events. First, why cachedAddress mechanism doesn't work if it's repair with
+     * bonded device case. Second, if it's not the case, log how many devices with the same model Id
+     * is already paired.
+     */
+    private void logPairWithModelIdInCacheAndDiscoveryFailForCachedAddress(BluetoothDevice device) {
+        if (!mPreferences.getLogPairWithCachedModelId()) {
+            return;
+        }
+
+        if (device.getBondState() == BOND_BONDED) {
+            if (mPreferences.getSameModelIdPairedDeviceCount() <= 0) {
+                Log.i(TAG, "Device is bonded but we don't have this model Id in cache.");
+            } else if (TextUtils.isEmpty(mPreferences.getCachedDeviceAddress())
+                    && mPreferences.getDirectConnectProfileIfModelIdInCache()
+                    && !mPreferences.getSkipConnectingProfiles()) {
+                // Pair with bonded device case. Log why the cached address is not found.
+                mEventLogger.setCurrentEvent(
+                        EventCode.DIRECTLY_CONNECT_PROFILE_WITH_CACHED_ADDRESS);
+                mEventLogger.logCurrentEventFailed(
+                        mPreferences.getIsDeviceFinishCheckAddressFromCache()
+                                ? new ConnectException(ConnectErrorCode.FAIL_TO_DISCOVERY,
+                                "Failed to discovery")
+                                : new ConnectException(
+                                        ConnectErrorCode.DISCOVERY_NOT_FINISHED,
+                                        "Discovery not finished"));
+                Log.i(TAG, "Failed to get cached address due to "
+                        + (mPreferences.getIsDeviceFinishCheckAddressFromCache()
+                        ? "Failed to discovery"
+                        : "Discovery not finished"));
+            }
+        } else if (device.getBondState() == BOND_NONE) {
+            // Pair with new device case, log how many devices with the same model id is in FastPair
+            // cache already.
+            mEventLogger.setCurrentEvent(EventCode.PAIR_WITH_NEW_MODEL);
+            if (mPreferences.getSameModelIdPairedDeviceCount() <= 0) {
+                mEventLogger.logCurrentEventSucceeded();
+            } else {
+                mEventLogger.logCurrentEventFailed(
+                        new BluetoothGattException(
+                                "Already have this model ID in cache",
+                                GATT_ERROR_CODE_PAIR_WITH_SAME_MODEL_ID_COUNT
+                                        + mPreferences.getSameModelIdPairedDeviceCount()));
+            }
+            Log.i(TAG, "This device already has " + mPreferences.getSameModelIdPairedDeviceCount()
+                    + " peripheral with the same model Id");
+        }
+    }
+
+    /**
+     * Attempts to directly connect to any supported profile if we're already bonded, this will save
+     * time over tearing down the bond and recreating it.
+     */
+    private void attemptDirectConnectionIfBonded(BluetoothDevice device,
+            BluetoothAudioPairer pairer)
+            throws PairingException {
+        if (mPreferences.getSkipConnectingProfiles()) {
+            if (mPreferences.getCheckBondStateWhenSkipConnectingProfiles()
+                    && device.getBondState() == BluetoothDevice.BOND_BONDED) {
+                Log.i(TAG, "Skipping connecting to profiles by preferences.");
+                return;
+            }
+            throw new PairingException(
+                    "Skipping connecting to profiles, no direct connection possible.");
+        } else if (!mPreferences.getAttemptDirectConnectionWhenPreviouslyBonded()
+                || device.getBondState() != BluetoothDevice.BOND_BONDED) {
+            throw new PairingException(
+                    "Not previously bonded skipping direct connection, %s", device.getBondState());
+        }
+        short[] supportedProfiles = getSupportedProfiles(device);
+        mEventLogger.setCurrentEvent(EventCode.DIRECTLY_CONNECTED_TO_PROFILE);
+        try (ScopedTiming scopedTiming =
+                new ScopedTiming(mTimingLogger, "Connect to profile directly")) {
+            attemptConnectProfiles(
+                    pairer,
+                    maskBluetoothAddress(device),
+                    supportedProfiles,
+                    mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()
+                            ? mPreferences.getNumConnectAttempts()
+                            : 1,
+                    mPreferences.getEnablePairingWhileDirectlyConnecting());
+            Log.i(TAG, "Directly connected to " + maskBluetoothAddress(device));
+            mEventLogger.logCurrentEventSucceeded();
+        } catch (PairingException e) {
+            mEventLogger.logCurrentEventFailed(e);
+            // Rethrow e so that the exception bubbles up and we continue the normal pairing
+            // process.
+            throw e;
+        }
+    }
+
+    @VisibleForTesting
+    void attemptConnectProfiles(
+            BluetoothAudioPairer pairer,
+            String deviceMaskedBluetoothAddress,
+            short[] profiles,
+            int numConnectionAttempts,
+            boolean enablePairingBehavior)
+            throws PairingException {
+        attemptConnectProfiles(
+                pairer,
+                deviceMaskedBluetoothAddress,
+                profiles,
+                numConnectionAttempts,
+                enablePairingBehavior,
+                new AtomicBoolean(false));
+    }
+
+    private void attemptConnectProfiles(
+            BluetoothAudioPairer pairer,
+            String deviceMaskedBluetoothAddress,
+            short[] profiles,
+            int numConnectionAttempts,
+            boolean enablePairingBehavior,
+            AtomicBoolean interruptConnection)
+            throws PairingException {
+        if (mPreferences.getMoreEventLogForQuality()) {
+            mEventLogger.setCurrentEvent(EventCode.BEFORE_CONNECT_PROFILE);
+        }
+        Exception lastException = null;
+        for (short profile : profiles) {
+            if (interruptConnection.get()) {
+                Log.w(TAG, "attemptConnectProfiles interrupted");
+                break;
+            }
+            if (!mPreferences.isSupportedProfile(profile)) {
+                Log.w(TAG, "Ignoring unsupported profile=" + profile);
+                continue;
+            }
+            for (int i = 1; i <= numConnectionAttempts; i++) {
+                if (interruptConnection.get()) {
+                    Log.w(TAG, "attemptConnectProfiles interrupted");
+                    break;
+                }
+                mEventLogger.setCurrentEvent(EventCode.CONNECT_PROFILE);
+                mEventLogger.setCurrentProfile(profile);
+                try {
+                    pairer.connect(profile, enablePairingBehavior);
+                    mEventLogger.logCurrentEventSucceeded();
+                    if (mPreferences.getMoreEventLogForQuality()) {
+                        // For EventCode.BEFORE_CONNECT_PROFILE
+                        mEventLogger.logCurrentEventSucceeded();
+                    }
+                    // If successful, we're done.
+                    // TODO(b/37167120): Connect to more than one profile.
+                    return;
+                } catch (InterruptedException
+                        | ReflectionException
+                        | TimeoutException
+                        | ExecutionException
+                        | ConnectException e) {
+                    Log.w(TAG,
+                            "Error connecting to profile=" + profile
+                                    + " for device=" + deviceMaskedBluetoothAddress
+                                    + " (attempt " + i + " of " + mPreferences
+                                    .getNumConnectAttempts(), e);
+                    mEventLogger.logCurrentEventFailed(e);
+                    lastException = e;
+                }
+            }
+        }
+        if (mPreferences.getMoreEventLogForQuality()) {
+            // For EventCode.BEFORE_CONNECT_PROFILE
+            if (lastException != null) {
+                mEventLogger.logCurrentEventFailed(lastException);
+            } else {
+                mEventLogger.logCurrentEventSucceeded();
+            }
+        }
+        throw new PairingException(
+                "Unable to connect to any profiles in: %s", Arrays.toString(profiles));
+    }
+
+    /**
+     * Checks whether or not an account key should be written to the device and writes it if so.
+     * This is called after handle notifying the pairedCallback that we've finished pairing, because
+     * at this point the headset is ready to use.
+     */
+    @Nullable
+    private SharedSecret maybeWriteAccountKey(BluetoothDevice device)
+            throws InterruptedException, ExecutionException, TimeoutException,
+            NoSuchAlgorithmException,
+            BluetoothException {
+        if (!sTestMode) {
+            Locator.get(mContext, FastPairController.class).setShouldUpload(false);
+        }
+        if (!shouldWriteAccountKey()) {
+            // For FastPair 2.0, here should be a subsequent pairing case.
+            return null;
+        }
+
+        // Check if it should be a subsequent pairing but go through initial pairing. If there is an
+        // existed paired history found, use the same account key instead of creating a new one.
+        byte[] accountKey =
+                mPairedHistoryFinder == null ? null : mPairedHistoryFinder.getExistingAccountKey();
+        if (accountKey == null) {
+            // It is a real initial pairing, generate a new account key for the headset.
+            try (ScopedTiming scopedTiming1 =
+                    new ScopedTiming(mTimingLogger, "Write account key")) {
+                accountKey = doWriteAccountKey(createAccountKey(), device.getAddress());
+                if (accountKey == null) {
+                    // Without writing account key back to provider, close the connection.
+                    mGattConnectionManager.closeConnection();
+                    return null;
+                }
+                if (!mPreferences.getIsRetroactivePairing()) {
+                    try (ScopedTiming scopedTiming2 = new ScopedTiming(mTimingLogger,
+                            "Start CloudSyncing")) {
+                        // Start to sync to the footprint
+                        Locator.get(mContext, FastPairController.class).setShouldUpload(true);
+                        //mContext.startService(createCloudSyncingIntent(accountKey));
+                    } catch (SecurityException e) {
+                        Log.w(TAG, "Error adding device.", e);
+                    }
+                }
+            }
+        } else if (shouldWriteAccountKeyForExistingCase(accountKey)) {
+            // There is an existing account key, but go through initial pairing, and still write the
+            // existing account key.
+            doWriteAccountKey(accountKey, device.getAddress());
+        }
+
+        // When finish writing account key in initial pairing, write new device name back to
+        // provider.
+        UUID characteristicUuid = NameCharacteristic.getId(mGattConnectionManager.getConnection());
+        if (mPreferences.getEnableNamingCharacteristic()
+                && mNeedUpdateProviderName
+                && validateBluetoothGattCharacteristic(
+                mGattConnectionManager.getConnection(), characteristicUuid)) {
+            try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                    "WriteNameToProvider")) {
+                writeNameToProvider(this.mProviderDeviceName, device.getAddress());
+            }
+        }
+
+        // When finish writing account key and name back to provider, close the connection.
+        mGattConnectionManager.closeConnection();
+        return SharedSecret.create(accountKey, device.getAddress());
+    }
+
+    private boolean shouldWriteAccountKey() {
+        return isWritingAccountKeyEnabled() && isPairingWithAntiSpoofingPublicKey();
+    }
+
+    private boolean isWritingAccountKeyEnabled() {
+        return mPreferences.getNumWriteAccountKeyAttempts() > 0;
+    }
+
+    private boolean isPairingWithAntiSpoofingPublicKey() {
+        return isPairingWithAntiSpoofingPublicKey(mPairingKey);
+    }
+
+    private boolean isPairingWithAntiSpoofingPublicKey(@Nullable byte[] key) {
+        return key != null && key.length == EllipticCurveDiffieHellmanExchange.PUBLIC_KEY_LENGTH;
+    }
+
+    /**
+     * Creates and writes an account key to the provided mac address.
+     */
+    @Nullable
+    private byte[] doWriteAccountKey(byte[] accountKey, String macAddress)
+            throws InterruptedException, ExecutionException, TimeoutException, BluetoothException {
+        byte[] localPairingSecret = mPairingSecret;
+        if (localPairingSecret == null) {
+            Log.w(TAG, "Pairing secret was null, account key couldn't be encrypted or written.");
+            return null;
+        }
+        if (!mPreferences.getSkipDisconnectingGattBeforeWritingAccountKey()) {
+            try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                    "Close GATT and sleep")) {
+                // Make a new connection instead of reusing gattConnection, because this is
+                // post-pairing and we need an encrypted connection.
+                mGattConnectionManager.closeConnection();
+                // Sleep before re-connecting to gatt, for writing account key, could increase
+                // stability.
+                Thread.sleep(mPreferences.getWriteAccountKeySleepMillis());
+            }
+        }
+
+        byte[] encryptedKey;
+        try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Encrypt key")) {
+            encryptedKey = AesEcbSingleBlockEncryption.encrypt(localPairingSecret, accountKey);
+        } catch (GeneralSecurityException e) {
+            Log.w("Failed to encrypt key.", e);
+            return null;
+        }
+
+        for (int i = 1; i <= mPreferences.getNumWriteAccountKeyAttempts(); i++) {
+            mEventLogger.setCurrentEvent(EventCode.WRITE_ACCOUNT_KEY);
+            try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+                    "Write key via GATT #" + i)) {
+                writeAccountKey(encryptedKey, macAddress);
+                mEventLogger.logCurrentEventSucceeded();
+                return accountKey;
+            } catch (BluetoothException e) {
+                Log.w("Error writing account key attempt " + i + " of " + mPreferences
+                        .getNumWriteAccountKeyAttempts(), e);
+                mEventLogger.logCurrentEventFailed(e);
+                // Retry with a while for stability.
+                Thread.sleep(mPreferences.getWriteAccountKeySleepMillis());
+            }
+        }
+        return null;
+    }
+
+    private byte[] createAccountKey() throws NoSuchAlgorithmException {
+        return AccountKeyGenerator.createAccountKey();
+    }
+
+    @VisibleForTesting
+    boolean shouldWriteAccountKeyForExistingCase(byte[] existingAccountKey) {
+        if (!mPreferences.getKeepSameAccountKeyWrite()) {
+            Log.i(TAG,
+                    "The provider has already paired with the account, skip writing account key.");
+            return false;
+        }
+        if (existingAccountKey[0] != AccountKeyCharacteristic.TYPE) {
+            Log.i(TAG,
+                    "The provider has already paired with the account, but accountKey[0] != 0x04."
+                            + " Forget the device from the account and re-try");
+
+            return false;
+        }
+        Log.i(TAG, "The provider has already paired with the account, still write the same account "
+                + "key.");
+        return true;
+    }
+
+    /**
+     * Performs a key-based pairing request handshake to authenticate and get the remote device's
+     * public address.
+     *
+     * @param key is described in {@link #pair(byte[])}
+     */
+    @VisibleForTesting
+    SharedSecret handshakeForKeyBasedPairing(byte[] key)
+            throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+            GeneralSecurityException, PairingException {
+        // We may also initialize gattConnectionManager of prepareForHandshake() that will be used
+        // in registerNotificationForNamePacket(), so we need to call it here.
+        HandshakeHandler handshakeHandler = prepareForHandshake();
+        KeyBasedPairingRequest.Builder keyBasedPairingRequestBuilder =
+                new KeyBasedPairingRequest.Builder()
+                        .setVerificationData(BluetoothAddress.decode(mBleAddress));
+        if (mProviderInitiatesBonding) {
+            keyBasedPairingRequestBuilder
+                    .addFlag(KeyBasedPairingRequestFlag.PROVIDER_INITIATES_BONDING);
+        }
+        // Seeker only request provider device name in initial pairing.
+        if (mPreferences.getEnableNamingCharacteristic() && isPairingWithAntiSpoofingPublicKey(
+                key)) {
+            keyBasedPairingRequestBuilder.addFlag(KeyBasedPairingRequestFlag.REQUEST_DEVICE_NAME);
+            // Register listener to receive name characteristic response from provider.
+            registerNotificationForNamePacket();
+        }
+        if (mPreferences.getIsRetroactivePairing()) {
+            keyBasedPairingRequestBuilder
+                    .addFlag(KeyBasedPairingRequestFlag.REQUEST_RETROACTIVE_PAIR);
+            keyBasedPairingRequestBuilder.setSeekerPublicAddress(
+                    Preconditions.checkNotNull(BluetoothAddress.getPublicAddress(mContext)));
+        }
+
+        return performHandshakeWithRetryAndSignalLostCheck(
+                handshakeHandler, key, keyBasedPairingRequestBuilder.build(), /* withRetry= */
+                true);
+    }
+
+    /**
+     * Performs an action-over-BLE request handshake for authentication, i.e. to identify the shared
+     * secret. The given key should be the account key.
+     */
+    private SharedSecret handshakeForActionOverBle(byte[] key,
+            @AdditionalDataType int additionalDataType)
+            throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+            GeneralSecurityException, PairingException {
+        HandshakeHandler handshakeHandler = prepareForHandshake();
+        return performHandshakeWithRetryAndSignalLostCheck(
+                handshakeHandler,
+                key,
+                new ActionOverBle.Builder()
+                        .setVerificationData(BluetoothAddress.decode(mBleAddress))
+                        .setAdditionalDataType(additionalDataType)
+                        .build(),
+                /* withRetry= */ false);
+    }
+
+    private HandshakeHandler prepareForHandshake() {
+        if (mGattConnectionManager == null) {
+            mGattConnectionManager =
+                    new GattConnectionManager(
+                            mContext,
+                            mPreferences,
+                            mEventLogger,
+                            mBluetoothAdapter,
+                            this::toggleBluetooth,
+                            mBleAddress,
+                            mTimingLogger,
+                            mFastPairSignalChecker,
+                            isPairingWithAntiSpoofingPublicKey());
+        }
+        if (mHandshakeHandlerForTest != null) {
+            Log.w(TAG, "Use handshakeHandlerForTest!");
+            return verifyNotNull(mHandshakeHandlerForTest);
+        }
+        return new HandshakeHandler(
+                mGattConnectionManager, mBleAddress, mPreferences, mEventLogger,
+                mFastPairSignalChecker);
+    }
+
+    @VisibleForTesting
+    void setHandshakeHandlerForTest(@Nullable HandshakeHandler handshakeHandlerForTest) {
+        this.mHandshakeHandlerForTest = handshakeHandlerForTest;
+    }
+
+    private SharedSecret performHandshakeWithRetryAndSignalLostCheck(
+            HandshakeHandler handshakeHandler,
+            byte[] key,
+            HandshakeMessage handshakeMessage,
+            boolean withRetry)
+            throws GeneralSecurityException, ExecutionException, BluetoothException,
+            InterruptedException, TimeoutException, PairingException {
+        SharedSecret handshakeResult =
+                withRetry
+                        ? handshakeHandler.doHandshakeWithRetryAndSignalLostCheck(
+                        key, handshakeMessage, mRescueFromError)
+                        : handshakeHandler.doHandshake(key, handshakeMessage);
+        // TODO: Try to remove these two global variables, publicAddress and pairingSecret.
+        mPublicAddress = handshakeResult.getAddress();
+        mPairingSecret = handshakeResult.getKey();
+        return handshakeResult;
+    }
+
+    private void toggleBluetooth()
+            throws InterruptedException, ExecutionException, TimeoutException {
+        if (!mPreferences.getToggleBluetoothOnFailure()) {
+            return;
+        }
+
+        Log.i(TAG, "Turning Bluetooth off.");
+        mEventLogger.setCurrentEvent(EventCode.DISABLE_BLUETOOTH);
+        mBluetoothAdapter.unwrap().disable();
+        disableBle(mBluetoothAdapter.unwrap());
+        try {
+            waitForBluetoothState(android.bluetooth.BluetoothAdapter.STATE_OFF);
+            mEventLogger.logCurrentEventSucceeded();
+        } catch (TimeoutException e) {
+            mEventLogger.logCurrentEventFailed(e);
+            // Soldier on despite failing to turn off Bluetooth. We can't control whether other
+            // clients (even inside GCore) kept it enabled in BLE-only mode.
+            Log.w(TAG, "Bluetooth still on. BluetoothAdapter state="
+                    + getBleState(mBluetoothAdapter.unwrap()), e);
+        }
+
+        // Note: Intentionally don't re-enable BLE-only mode, because we don't know which app
+        // enabled it. The client app should listen to Bluetooth events and enable as necessary
+        // (because the user can toggle at any time; e.g. via Airplane mode).
+        Log.i(TAG, "Turning Bluetooth on.");
+        mEventLogger.setCurrentEvent(EventCode.ENABLE_BLUETOOTH);
+        mBluetoothAdapter.unwrap().enable();
+        waitForBluetoothState(android.bluetooth.BluetoothAdapter.STATE_ON);
+        mEventLogger.logCurrentEventSucceeded();
+    }
+
+    private void waitForBluetoothState(int state)
+            throws TimeoutException, ExecutionException, InterruptedException {
+        waitForBluetoothStateUsingPolling(state);
+    }
+
+    private void waitForBluetoothStateUsingPolling(int state) throws TimeoutException {
+        // There's a bug where we (pretty often!) never get the broadcast for STATE_ON or STATE_OFF.
+        // So poll instead.
+        long start = SystemClock.elapsedRealtime();
+        long timeoutMillis = mPreferences.getBluetoothToggleTimeoutSeconds() * 1000L;
+        while (SystemClock.elapsedRealtime() - start < timeoutMillis) {
+            if (state == getBleState(mBluetoothAdapter.unwrap())) {
+                break;
+            }
+            SystemClock.sleep(mPreferences.getBluetoothStatePollingMillis());
+        }
+
+        if (state != getBleState(mBluetoothAdapter.unwrap())) {
+            throw new TimeoutException(
+                    String.format(
+                            Locale.getDefault(),
+                            "Timed out waiting for state %d, current state is %d",
+                            state,
+                            getBleState(mBluetoothAdapter.unwrap())));
+        }
+    }
+
+    private BrEdrHandoverInformation getBrEdrHandoverInformation(BluetoothGattConnection connection)
+            throws BluetoothException, TdsException, InterruptedException, ExecutionException,
+            TimeoutException {
+        Log.i(TAG, "Connecting GATT server to BLE address=" + maskBluetoothAddress(mBleAddress));
+        Log.i(TAG, "Telling device to become discoverable");
+        mEventLogger.setCurrentEvent(EventCode.BR_EDR_HANDOVER_WRITE_CONTROL_POINT_REQUEST);
+        ChangeObserver changeObserver =
+                connection.enableNotification(
+                        TransportDiscoveryService.ID,
+                        TransportDiscoveryService.ControlPointCharacteristic.ID);
+        connection.writeCharacteristic(
+                TransportDiscoveryService.ID,
+                TransportDiscoveryService.ControlPointCharacteristic.ID,
+                TDS_CONTROL_POINT_REQUEST);
+
+        byte[] response =
+                changeObserver.waitForUpdate(
+                        TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+        @ResultCode int resultCode = fromTdsControlPointIndication(response);
+        if (resultCode != ResultCode.SUCCESS) {
+            throw new TdsException(
+                    BrEdrHandoverErrorCode.CONTROL_POINT_RESULT_CODE_NOT_SUCCESS,
+                    "TDS Control Point result code (%s) was not success in response %s",
+                    resultCode,
+                    base16().lowerCase().encode(response));
+        }
+        mEventLogger.logCurrentEventSucceeded();
+        return new BrEdrHandoverInformation(
+                getAddressFromBrEdrConnection(connection),
+                getProfilesFromBrEdrConnection(connection));
+    }
+
+    private byte[] getAddressFromBrEdrConnection(BluetoothGattConnection connection)
+            throws BluetoothException, TdsException {
+        Log.i(TAG, "Getting Bluetooth MAC");
+        mEventLogger.setCurrentEvent(EventCode.BR_EDR_HANDOVER_READ_BLUETOOTH_MAC);
+        byte[] brHandoverData =
+                connection.readCharacteristic(
+                        TransportDiscoveryService.ID,
+                        to128BitUuid(mPreferences.getBrHandoverDataCharacteristicId()));
+        if (brHandoverData == null || brHandoverData.length < 7) {
+            throw new TdsException(
+                    BrEdrHandoverErrorCode.BLUETOOTH_MAC_INVALID,
+                    "Bluetooth MAC not contained in BR handover data: %s",
+                    brHandoverData != null ? base16().lowerCase().encode(brHandoverData)
+                            : "(none)");
+        }
+        byte[] bluetoothAddress =
+                new Bytes.Value(Arrays.copyOfRange(brHandoverData, 1, 7), ByteOrder.LITTLE_ENDIAN)
+                        .getBytes(ByteOrder.BIG_ENDIAN);
+        mEventLogger.logCurrentEventSucceeded();
+        return bluetoothAddress;
+    }
+
+    private short[] getProfilesFromBrEdrConnection(BluetoothGattConnection connection) {
+        mEventLogger.setCurrentEvent(EventCode.BR_EDR_HANDOVER_READ_TRANSPORT_BLOCK);
+        try {
+            byte[] transportBlock =
+                    connection.readDescriptor(
+                            TransportDiscoveryService.ID,
+                            to128BitUuid(mPreferences.getBluetoothSigDataCharacteristicId()),
+                            to128BitUuid(mPreferences.getBrTransportBlockDataDescriptorId()));
+            Log.i(TAG, "Got transport block: " + base16().lowerCase().encode(transportBlock));
+            short[] profiles = getSupportedProfiles(transportBlock);
+            mEventLogger.logCurrentEventSucceeded();
+            return profiles;
+        } catch (BluetoothException | TdsException | ParseException e) {
+            Log.w(TAG, "Failed to get supported profiles from transport block.", e);
+            mEventLogger.logCurrentEventFailed(e);
+        }
+        return new short[0];
+    }
+
+    @VisibleForTesting
+    boolean writeNameToProvider(@Nullable String deviceName, @Nullable String address)
+            throws InterruptedException, TimeoutException, ExecutionException {
+        if (deviceName == null || address == null) {
+            Log.i(TAG, "writeNameToProvider fail because provider name or address is null.");
+            return false;
+        }
+        if (mPairingSecret == null) {
+            Log.i(TAG, "writeNameToProvider fail because no pairingSecret.");
+            return false;
+        }
+        byte[] encryptedDeviceNamePacket;
+        try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Encode device name")) {
+            encryptedDeviceNamePacket =
+                    NamingEncoder.encodeNamingPacket(mPairingSecret, deviceName);
+        } catch (GeneralSecurityException e) {
+            Log.w(TAG, "Failed to encrypt device name.", e);
+            return false;
+        }
+
+        for (int i = 1; i <= mPreferences.getNumWriteAccountKeyAttempts(); i++) {
+            mEventLogger.setCurrentEvent(EventCode.WRITE_DEVICE_NAME);
+            try {
+                writeDeviceName(encryptedDeviceNamePacket, address);
+                mEventLogger.logCurrentEventSucceeded();
+                return true;
+            } catch (BluetoothException e) {
+                Log.w(TAG, "Error writing name attempt " + i + " of "
+                        + mPreferences.getNumWriteAccountKeyAttempts());
+                mEventLogger.logCurrentEventFailed(e);
+                // Reuses the existing preference because the same usage.
+                Thread.sleep(mPreferences.getWriteAccountKeySleepMillis());
+            }
+        }
+        return false;
+    }
+
+    private void writeAccountKey(byte[] encryptedAccountKey, String address)
+            throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+        Log.i(TAG, "Writing account key to address=" + maskBluetoothAddress(address));
+        BluetoothGattConnection connection = mGattConnectionManager.getConnection();
+        connection.setOperationTimeout(
+                TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+        UUID characteristicUuid = AccountKeyCharacteristic.getId(connection);
+        connection.writeCharacteristic(FastPairService.ID, characteristicUuid, encryptedAccountKey);
+        Log.i(TAG,
+                "Finished writing encrypted account key=" + base16().encode(encryptedAccountKey));
+    }
+
+    private void writeDeviceName(byte[] naming, String address)
+            throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+        Log.i(TAG, "Writing new device name to address=" + maskBluetoothAddress(address));
+        BluetoothGattConnection connection = mGattConnectionManager.getConnection();
+        connection.setOperationTimeout(
+                TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+        UUID characteristicUuid = NameCharacteristic.getId(connection);
+        connection.writeCharacteristic(FastPairService.ID, characteristicUuid, naming);
+        Log.i(TAG, "Finished writing new device name=" + base16().encode(naming));
+    }
+
+    /**
+     * Reads firmware version after write account key to provider since simulator is more stable to
+     * read firmware version in initial gatt connection. This function will also read firmware when
+     * detect bloomfilter. Need to verify this after real device come out. TODO(b/130592473)
+     */
+    @Nullable
+    public String readFirmwareVersion()
+            throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+        if (!TextUtils.isEmpty(sInitialConnectionFirmwareVersion)) {
+            String result = sInitialConnectionFirmwareVersion;
+            sInitialConnectionFirmwareVersion = null;
+            return result;
+        }
+        if (mGattConnectionManager == null) {
+            mGattConnectionManager =
+                    new GattConnectionManager(
+                            mContext,
+                            mPreferences,
+                            mEventLogger,
+                            mBluetoothAdapter,
+                            this::toggleBluetooth,
+                            mBleAddress,
+                            mTimingLogger,
+                            mFastPairSignalChecker,
+                            /* setMtu= */ true);
+            mGattConnectionManager.closeConnection();
+        }
+        if (sTestMode) {
+            return null;
+        }
+        BluetoothGattConnection connection = mGattConnectionManager.getConnection();
+        connection.setOperationTimeout(
+                TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+
+        try {
+            String firmwareVersion =
+                    new String(
+                            connection.readCharacteristic(
+                                    FastPairService.ID,
+                                    to128BitUuid(
+                                            mPreferences.getFirmwareVersionCharacteristicId())));
+            Log.i(TAG, "FastPair: Got the firmware info version number = " + firmwareVersion);
+            mGattConnectionManager.closeConnection();
+            return firmwareVersion;
+        } catch (BluetoothException e) {
+            Log.i(TAG, "FastPair: can't read firmware characteristic.", e);
+            mGattConnectionManager.closeConnection();
+            return null;
+        }
+    }
+
+    @VisibleForTesting
+    @Nullable
+    String getInitialConnectionFirmware() {
+        return sInitialConnectionFirmwareVersion;
+    }
+
+    private void registerNotificationForNamePacket()
+            throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+        Log.i(TAG,
+                "register for the device name response from address=" + maskBluetoothAddress(
+                        mBleAddress));
+
+        BluetoothGattConnection gattConnection = mGattConnectionManager.getConnection();
+        gattConnection.setOperationTimeout(
+                TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+        try {
+            mDeviceNameReceiver = new DeviceNameReceiver(gattConnection);
+        } catch (BluetoothException e) {
+            Log.i(TAG, "Can't register for device name response, no naming characteristic.");
+            return;
+        }
+    }
+
+    private short[] getSupportedProfiles(BluetoothDevice device) {
+        short[] supportedProfiles = getCachedUuids(device);
+        if (supportedProfiles.length == 0 && mPreferences.getNumSdpAttemptsAfterBonded() > 0) {
+            supportedProfiles =
+                    attemptGetBluetoothClassicProfiles(device,
+                            mPreferences.getNumSdpAttemptsAfterBonded());
+        }
+        if (supportedProfiles.length == 0) {
+            supportedProfiles = Constants.getSupportedProfiles();
+            Log.w(TAG, "Attempting to connect constants profiles, "
+                    + Arrays.toString(supportedProfiles));
+        } else {
+            Log.i(TAG,
+                    "Attempting to connect device profiles, " + Arrays.toString(supportedProfiles));
+        }
+        return supportedProfiles;
+    }
+
+    private static short[] getSupportedProfiles(byte[] transportBlock)
+            throws TdsException, ParseException {
+        if (transportBlock == null || transportBlock.length < 4) {
+            throw new TdsException(
+                    BrEdrHandoverErrorCode.TRANSPORT_BLOCK_INVALID,
+                    "Transport Block null or too short: %s",
+                    base16().lowerCase().encode(transportBlock));
+        }
+        int transportDataLength = transportBlock[2];
+        if (transportBlock.length < 3 + transportDataLength) {
+            throw new TdsException(
+                    BrEdrHandoverErrorCode.TRANSPORT_BLOCK_INVALID,
+                    "Transport Block has wrong length byte: %s",
+                    base16().lowerCase().encode(transportBlock));
+        }
+        byte[] transportData = Arrays.copyOfRange(transportBlock, 3, 3 + transportDataLength);
+        for (Ltv ltv : Ltv.parse(transportData)) {
+            int uuidLength = uuidLength(ltv.mType);
+            // We currently only support a single list of 2-byte UUIDs.
+            // TODO(b/37539535): Support multiple lists, and longer (32-bit, 128-bit) IDs?
+            if (uuidLength == 2) {
+                return toShorts(ByteOrder.LITTLE_ENDIAN, ltv.mValue);
+            }
+        }
+        return new short[0];
+    }
+
+    /**
+     * Returns 0 if the type is not one of the UUID list types; otherwise returns length in bytes.
+     */
+    private static int uuidLength(byte dataType) {
+        switch (dataType) {
+            case TransportDiscoveryService.SERVICE_UUIDS_16_BIT_LIST_TYPE:
+                return 2;
+            case TransportDiscoveryService.SERVICE_UUIDS_32_BIT_LIST_TYPE:
+                return 4;
+            case TransportDiscoveryService.SERVICE_UUIDS_128_BIT_LIST_TYPE:
+                return 16;
+            default:
+                return 0;
+        }
+    }
+
+    private short[] attemptGetBluetoothClassicProfiles(BluetoothDevice device, int numSdpAttempts) {
+        // The docs say that if fetchUuidsWithSdp() has an error or "takes a long time", we get an
+        // intent containing only the stuff in the cache (i.e. nothing). Retry a few times.
+        short[] supportedProfiles = null;
+        for (int i = 1; i <= numSdpAttempts; i++) {
+            mEventLogger.setCurrentEvent(EventCode.GET_PROFILES_VIA_SDP);
+            try (ScopedTiming scopedTiming =
+                    new ScopedTiming(mTimingLogger,
+                            "Get BR/EDR handover information via SDP #" + i)) {
+                supportedProfiles = getSupportedProfilesViaBluetoothClassic(device);
+            } catch (ExecutionException | InterruptedException | TimeoutException e) {
+                // Ignores and retries if needed.
+            }
+            if (supportedProfiles != null && supportedProfiles.length != 0) {
+                mEventLogger.logCurrentEventSucceeded();
+                break;
+            } else {
+                mEventLogger.logCurrentEventFailed(new TimeoutException());
+                Log.w(TAG, "SDP returned no UUIDs from " + maskBluetoothAddress(device.getAddress())
+                        + ", assuming timeout (attempt " + i + " of " + numSdpAttempts + ").");
+            }
+        }
+        return (supportedProfiles == null) ? new short[0] : supportedProfiles;
+    }
+
+    private short[] getSupportedProfilesViaBluetoothClassic(BluetoothDevice device)
+            throws ExecutionException, InterruptedException, TimeoutException {
+        Log.i(TAG, "Getting supported profiles via SDP (Bluetooth Classic) for "
+                + maskBluetoothAddress(device.getAddress()));
+        try (DeviceIntentReceiver supportedProfilesReceiver =
+                DeviceIntentReceiver.oneShotReceiver(
+                        mContext, mPreferences, device, BluetoothDevice.ACTION_UUID)) {
+            device.fetchUuidsWithSdp();
+            supportedProfilesReceiver.await(mPreferences.getSdpTimeoutSeconds(), TimeUnit.SECONDS);
+        }
+        return getCachedUuids(device);
+    }
+
+    private static short[] getCachedUuids(BluetoothDevice device) {
+        ParcelUuid[] parcelUuids = device.getUuids();
+        Log.i(TAG, "Got supported UUIDs: " + Arrays.toString(parcelUuids));
+        if (parcelUuids == null) {
+            // The OS can return null.
+            parcelUuids = new ParcelUuid[0];
+        }
+
+        List<Short> shortUuids = new ArrayList<>(parcelUuids.length);
+        for (ParcelUuid parcelUuid : parcelUuids) {
+            UUID uuid = parcelUuid.getUuid();
+            if (BluetoothUuids.is16BitUuid(uuid)) {
+                shortUuids.add(get16BitUuid(uuid));
+            }
+        }
+        return Shorts.toArray(shortUuids);
+    }
+
+    private void callbackOnPaired() {
+        if (mPairedCallback != null) {
+            mPairedCallback.onPaired(mPublicAddress != null ? mPublicAddress : mBleAddress);
+        }
+    }
+
+    private void callbackOnGetAddress(String address) {
+        if (mOnGetBluetoothAddressCallback != null) {
+            mOnGetBluetoothAddressCallback.onGetBluetoothAddress(address);
+        }
+    }
+
+    private boolean validateBluetoothGattCharacteristic(
+            BluetoothGattConnection connection, UUID characteristicUUID) {
+        try (ScopedTiming scopedTiming =
+                new ScopedTiming(mTimingLogger, "Get service characteristic list")) {
+            List<BluetoothGattCharacteristic> serviceCharacteristicList =
+                    connection.getService(FastPairService.ID).getCharacteristics();
+            for (BluetoothGattCharacteristic characteristic : serviceCharacteristicList) {
+                if (characteristicUUID.equals(characteristic.getUuid())) {
+                    Log.i(TAG, "characteristic is exists, uuid = " + characteristicUUID);
+                    return true;
+                }
+            }
+        } catch (BluetoothException e) {
+            Log.w(TAG, "Can't get service characteristic list.", e);
+        }
+        Log.i(TAG, "can't find characteristic, uuid = " + characteristicUUID);
+        return false;
+    }
+
+    // This method is only for testing to make test method block until get name response or time
+    // out.
+    /**
+     * Set name response countdown latch.
+     */
+    public void setNameResponseCountDownLatch(CountDownLatch countDownLatch) {
+        if (mDeviceNameReceiver != null) {
+            mDeviceNameReceiver.setCountDown(countDownLatch);
+            Log.v(TAG, "set up nameResponseCountDown");
+        }
+    }
+
+    private static int getBleState(android.bluetooth.BluetoothAdapter bluetoothAdapter) {
+        // Can't use the public isLeEnabled() API, because it returns false for
+        // STATE_BLE_TURNING_(ON|OFF). So if we assume false == STATE_OFF, that can be
+        // very wrong.
+        return getLeState(bluetoothAdapter);
+    }
+
+    private static int getLeState(android.bluetooth.BluetoothAdapter adapter) {
+        try {
+            return (Integer) Reflect.on(adapter).withMethod("getLeState").get();
+        } catch (ReflectionException e) {
+            Log.i(TAG, "Can't call getLeState", e);
+        }
+        return adapter.getState();
+    }
+
+    private static void disableBle(android.bluetooth.BluetoothAdapter adapter) {
+        adapter.disableBLE();
+    }
+
+    /**
+     * Handle the searching of Fast Pair history. Since there is only one public address using
+     * during Fast Pair connection, {@link #isInPairedHistory(String)} only needs to be called once,
+     * then the result is kept, and call {@link #getExistingAccountKey()} to get the result.
+     */
+    @VisibleForTesting
+    static final class FastPairHistoryFinder {
+
+        private @Nullable
+        byte[] mExistingAccountKey;
+        @Nullable
+        private final List<FastPairHistoryItem> mHistoryItems;
+
+        FastPairHistoryFinder(List<FastPairHistoryItem> historyItems) {
+            this.mHistoryItems = historyItems;
+        }
+
+        @WorkerThread
+        @VisibleForTesting
+        boolean isInPairedHistory(String publicAddress) {
+            if (mHistoryItems == null || mHistoryItems.isEmpty()) {
+                return false;
+            }
+            for (FastPairHistoryItem item : mHistoryItems) {
+                if (item.isMatched(BluetoothAddress.decode(publicAddress))) {
+                    mExistingAccountKey = item.accountKey().toByteArray();
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        // This function should be called after isInPairedHistory(). Or it will just return null.
+        @WorkerThread
+        @VisibleForTesting
+        @Nullable
+        byte[] getExistingAccountKey() {
+            return mExistingAccountKey;
+        }
+    }
+
+    private static final class DeviceNameReceiver {
+
+        @GuardedBy("this")
+        private @Nullable
+        byte[] mEncryptedResponse;
+
+        @GuardedBy("this")
+        @Nullable
+        private String mDecryptedDeviceName;
+
+        @Nullable
+        private CountDownLatch mResponseCountDown;
+
+        DeviceNameReceiver(BluetoothGattConnection gattConnection) throws BluetoothException {
+            UUID characteristicUuid = NameCharacteristic.getId(gattConnection);
+            ChangeObserver observer =
+                    gattConnection.enableNotification(FastPairService.ID, characteristicUuid);
+            observer.setListener(
+                    (byte[] value) -> {
+                        synchronized (DeviceNameReceiver.this) {
+                            Log.i(TAG, "DeviceNameReceiver: device name response size = "
+                                    + value.length);
+                            // We don't decrypt it here because we may not finish handshaking and
+                            // the pairing
+                            // secret is not available.
+                            mEncryptedResponse = value;
+                        }
+                        // For testing to know we get the device name from provider.
+                        if (mResponseCountDown != null) {
+                            mResponseCountDown.countDown();
+                            Log.v(TAG, "Finish nameResponseCountDown.");
+                        }
+                    });
+        }
+
+        void setCountDown(CountDownLatch countDownLatch) {
+            this.mResponseCountDown = countDownLatch;
+        }
+
+        synchronized @Nullable String getParsedResult(byte[] secret) {
+            if (mDecryptedDeviceName != null) {
+                return mDecryptedDeviceName;
+            }
+            if (mEncryptedResponse == null) {
+                Log.i(TAG, "DeviceNameReceiver: no device name sent from the Provider.");
+                return null;
+            }
+            try {
+                mDecryptedDeviceName = NamingEncoder.decodeNamingPacket(secret, mEncryptedResponse);
+                Log.i(TAG, "DeviceNameReceiver: decrypted provider's name from naming response, "
+                        + "name = " + mDecryptedDeviceName);
+            } catch (GeneralSecurityException e) {
+                Log.w(TAG, "DeviceNameReceiver: fail to parse the NameCharacteristic from provider"
+                        + ".", e);
+                return null;
+            }
+            return mDecryptedDeviceName;
+        }
+    }
+
+    static void checkFastPairSignal(
+            FastPairSignalChecker fastPairSignalChecker,
+            String currentAddress,
+            Exception originalException)
+            throws SignalLostException, SignalRotatedException {
+        String newAddress = fastPairSignalChecker.getValidAddressForModelId(currentAddress);
+        if (TextUtils.isEmpty(newAddress)) {
+            throw new SignalLostException("Signal lost", originalException);
+        } else if (!Ascii.equalsIgnoreCase(currentAddress, newAddress)) {
+            throw new SignalRotatedException("Address rotated", newAddress, originalException);
+        }
+    }
+
+    @VisibleForTesting
+    public Preferences getPreferences() {
+        return mPreferences;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairHistoryItem.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairHistoryItem.java
new file mode 100644
index 0000000..e774886
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairHistoryItem.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import com.google.common.hash.Hashing;
+import com.google.protobuf.ByteString;
+
+import java.util.Arrays;
+
+/**
+ * It contains the sha256 of "account key + headset's public address" to identify the headset which
+ * has paired with the account. Previously, account key is the only information for Fast Pair to
+ * identify the headset, but Fast Pair can't identify the headset in initial pairing, there is no
+ * account key data advertising from headset.
+ */
+public class FastPairHistoryItem {
+
+    private final ByteString mAccountKey;
+    private final ByteString mSha256AccountKeyPublicAddress;
+
+    FastPairHistoryItem(ByteString accountkey, ByteString sha256AccountKeyPublicAddress) {
+        mAccountKey = accountkey;
+        mSha256AccountKeyPublicAddress = sha256AccountKeyPublicAddress;
+    }
+
+    /**
+     * Creates an instance of {@link FastPairHistoryItem}.
+     *
+     * @param accountKey key of an account that has paired with the headset.
+     * @param sha256AccountKeyPublicAddress hash value of account key and headset's public address.
+     */
+    public static FastPairHistoryItem create(
+            ByteString accountKey, ByteString sha256AccountKeyPublicAddress) {
+        return new FastPairHistoryItem(accountKey, sha256AccountKeyPublicAddress);
+    }
+
+    ByteString accountKey() {
+        return mAccountKey;
+    }
+
+    ByteString sha256AccountKeyPublicAddress() {
+        return mSha256AccountKeyPublicAddress;
+    }
+
+    // Return true if the input public address is considered the same as this history item. Because
+    // of privacy concern, Fast Pair does not really store the public address, it is identified by
+    // the SHA256 of the account key and the public key.
+    final boolean isMatched(byte[] publicAddress) {
+        return Arrays.equals(
+                sha256AccountKeyPublicAddress().toByteArray(),
+                Hashing.sha256().hashBytes(concat(accountKey().toByteArray(), publicAddress))
+                        .asBytes());
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManager.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManager.java
new file mode 100644
index 0000000..e7ce4bf
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManager.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.AES_BLOCK_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+import static com.android.server.nearby.common.bluetooth.fastpair.FastPairDualConnection.logRetrySuccessEvent;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.util.Consumer;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.BluetoothTimeoutException;
+import com.android.server.nearby.common.bluetooth.fastpair.TimingLogger.ScopedTiming;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.ConnectionOptions;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ErrorCode;
+import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Manager for working with Gatt connections.
+ *
+ * <p>This helper class allows for opening and closing GATT connections to a provided address.
+ * Optionally, it can also support automatically reopening a connection in the case that it has been
+ * closed when it's next needed through {@link Preferences#getAutomaticallyReconnectGattWhenNeeded}.
+ */
+// TODO(b/202524672): Add class unit test.
+final class GattConnectionManager {
+
+    private static final String TAG = GattConnectionManager.class.getSimpleName();
+
+    private final Context mContext;
+    private final Preferences mPreferences;
+    private final EventLoggerWrapper mEventLogger;
+    private final BluetoothAdapter mBluetoothAdapter;
+    private final ToggleBluetoothTask mToggleBluetooth;
+    private final String mAddress;
+    private final TimingLogger mTimingLogger;
+    private final boolean mSetMtu;
+    @Nullable
+    private final FastPairConnection.FastPairSignalChecker mFastPairSignalChecker;
+    @Nullable
+    private BluetoothGattConnection mGattConnection;
+    private static boolean sTestMode = false;
+
+    static void enableTestMode() {
+        sTestMode = true;
+    }
+
+    GattConnectionManager(
+            Context context,
+            Preferences preferences,
+            EventLoggerWrapper eventLogger,
+            BluetoothAdapter bluetoothAdapter,
+            ToggleBluetoothTask toggleBluetooth,
+            String address,
+            TimingLogger timingLogger,
+            @Nullable FastPairConnection.FastPairSignalChecker fastPairSignalChecker,
+            boolean setMtu) {
+        this.mContext = context;
+        this.mPreferences = preferences;
+        this.mEventLogger = eventLogger;
+        this.mBluetoothAdapter = bluetoothAdapter;
+        this.mToggleBluetooth = toggleBluetooth;
+        this.mAddress = address;
+        this.mTimingLogger = timingLogger;
+        this.mFastPairSignalChecker = fastPairSignalChecker;
+        this.mSetMtu = setMtu;
+    }
+
+    /**
+     * Gets a gatt connection to address. If this connection does not exist, it creates one.
+     */
+    BluetoothGattConnection getConnection()
+            throws InterruptedException, ExecutionException, TimeoutException, BluetoothException {
+        if (mGattConnection == null) {
+            try {
+                mGattConnection =
+                        connect(mAddress, /* checkSignalWhenFail= */ false,
+                                /* rescueFromError= */ null);
+            } catch (SignalLostException | SignalRotatedException e) {
+                // Impossible to happen here because we didn't do signal check.
+                throw new ExecutionException("getConnection throws SignalLostException", e);
+            }
+        }
+        return mGattConnection;
+    }
+
+    BluetoothGattConnection getConnectionWithSignalLostCheck(
+            @Nullable Consumer<Integer> rescueFromError)
+            throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+            SignalLostException, SignalRotatedException {
+        if (mGattConnection == null) {
+            mGattConnection = connect(mAddress, /* checkSignalWhenFail= */ true,
+                    rescueFromError);
+        }
+        return mGattConnection;
+    }
+
+    /**
+     * Closes the gatt connection when it is open.
+     */
+    void closeConnection() throws BluetoothException {
+        if (mGattConnection != null) {
+            try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Close GATT")) {
+                mGattConnection.close();
+                mGattConnection = null;
+            }
+        }
+    }
+
+    private BluetoothGattConnection connect(
+            String address, boolean checkSignalWhenFail,
+            @Nullable Consumer<Integer> rescueFromError)
+            throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+            SignalLostException, SignalRotatedException {
+        int i = 1;
+        boolean isRecoverable = true;
+        long startElapsedRealtime = SystemClock.elapsedRealtime();
+        BluetoothException lastException = null;
+        mEventLogger.setCurrentEvent(EventCode.GATT_CONNECT);
+        while (isRecoverable) {
+            try (ScopedTiming scopedTiming =
+                    new ScopedTiming(mTimingLogger, "Connect GATT #" + i)) {
+                Log.i(TAG, "Connecting to GATT server at " + maskBluetoothAddress(address));
+                if (sTestMode) {
+                    return null;
+                }
+                BluetoothGattConnection connection =
+                        new BluetoothGattHelper(mContext, mBluetoothAdapter)
+                                .connect(
+                                        mBluetoothAdapter.getRemoteDevice(address),
+                                        getConnectionOptions(startElapsedRealtime));
+                connection.setOperationTimeout(
+                        TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+                if (mPreferences.getAutomaticallyReconnectGattWhenNeeded()) {
+                    connection.addCloseListener(
+                            () -> {
+                                Log.i(TAG, "Gatt connection with " + maskBluetoothAddress(address)
+                                        + " closed.");
+                                mGattConnection = null;
+                            });
+                }
+                mEventLogger.logCurrentEventSucceeded();
+                if (lastException != null) {
+                    logRetrySuccessEvent(EventCode.RECOVER_BY_RETRY_GATT, lastException,
+                            mEventLogger);
+                }
+                return connection;
+            } catch (BluetoothException e) {
+                lastException = e;
+
+                boolean ableToRetry;
+                if (mPreferences.getGattConnectRetryTimeoutMillis() > 0) {
+                    ableToRetry =
+                            (SystemClock.elapsedRealtime() - startElapsedRealtime)
+                                    < mPreferences.getGattConnectRetryTimeoutMillis();
+                    Log.i(TAG, "Retry connecting GATT by timeout: " + ableToRetry);
+                } else {
+                    ableToRetry = i < mPreferences.getNumAttempts();
+                }
+
+                if (mPreferences.getRetryGattConnectionAndSecretHandshake()) {
+                    if (isNoRetryError(mPreferences, e)) {
+                        ableToRetry = false;
+                    }
+
+                    if (ableToRetry) {
+                        if (rescueFromError != null) {
+                            rescueFromError.accept(
+                                    e instanceof BluetoothOperationTimeoutException
+                                            ? ErrorCode.SUCCESS_RETRY_GATT_TIMEOUT
+                                            : ErrorCode.SUCCESS_RETRY_GATT_ERROR);
+                        }
+                        if (mFastPairSignalChecker != null && checkSignalWhenFail) {
+                            FastPairDualConnection
+                                    .checkFastPairSignal(mFastPairSignalChecker, address, e);
+                        }
+                    }
+                    isRecoverable = ableToRetry;
+                    if (ableToRetry && mPreferences.getPairingRetryDelayMs() > 0) {
+                        SystemClock.sleep(mPreferences.getPairingRetryDelayMs());
+                    }
+                } else {
+                    isRecoverable =
+                            ableToRetry
+                                    && (e instanceof BluetoothOperationTimeoutException
+                                    || e instanceof BluetoothTimeoutException
+                                    || (e instanceof BluetoothGattException
+                                    && ((BluetoothGattException) e).getGattErrorCode() == 133));
+                }
+                Log.w(TAG, "GATT connect attempt " + i + "of " + mPreferences.getNumAttempts()
+                        + " failed, " + (isRecoverable ? "recovering" : "permanently"), e);
+                if (isRecoverable) {
+                    // If we're going to retry, log failure here. If we throw, an upper level will
+                    // log it.
+                    mToggleBluetooth.toggleBluetooth();
+                    i++;
+                    mEventLogger.logCurrentEventFailed(e);
+                    mEventLogger.setCurrentEvent(EventCode.GATT_CONNECT);
+                }
+            }
+        }
+        throw checkNotNull(lastException);
+    }
+
+    static boolean isNoRetryError(Preferences preferences, BluetoothException e) {
+        return e instanceof BluetoothGattException
+                && preferences
+                .getGattConnectionAndSecretHandshakeNoRetryGattError()
+                .contains(((BluetoothGattException) e).getGattErrorCode());
+    }
+
+    @VisibleForTesting
+    long getTimeoutMs(long spentTime) {
+        long timeoutInMs;
+        if (mPreferences.getRetryGattConnectionAndSecretHandshake()) {
+            timeoutInMs =
+                    spentTime < mPreferences.getGattConnectShortTimeoutRetryMaxSpentTimeMs()
+                            ? mPreferences.getGattConnectShortTimeoutMs()
+                            : mPreferences.getGattConnectLongTimeoutMs();
+        } else {
+            timeoutInMs = TimeUnit.SECONDS.toMillis(mPreferences.getGattConnectionTimeoutSeconds());
+        }
+        return timeoutInMs;
+    }
+
+    private ConnectionOptions getConnectionOptions(long startElapsedRealtime) {
+        return createConnectionOptions(
+                mSetMtu,
+                getTimeoutMs(SystemClock.elapsedRealtime() - startElapsedRealtime));
+    }
+
+    public static ConnectionOptions createConnectionOptions(boolean setMtu, long timeoutInMs) {
+        ConnectionOptions.Builder builder = ConnectionOptions.builder();
+        if (setMtu) {
+            // There are 3 overhead bytes added to BLE packets.
+            builder.setMtu(
+                    AES_BLOCK_LENGTH + EllipticCurveDiffieHellmanExchange.PUBLIC_KEY_LENGTH + 3);
+        }
+        builder.setConnectionTimeoutMillis(timeoutInMs);
+        return builder.build();
+    }
+
+    @VisibleForTesting
+    void setGattConnection(BluetoothGattConnection gattConnection) {
+        this.mGattConnection = gattConnection;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HandshakeHandler.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HandshakeHandler.java
new file mode 100644
index 0000000..984133b
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HandshakeHandler.java
@@ -0,0 +1,560 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.AES_BLOCK_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.decrypt;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.encrypt;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.BLUETOOTH_ADDRESS_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.ActionOverBleFlag.ADDITIONAL_DATA_CHARACTERISTIC;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.ActionOverBleFlag.DEVICE_ACTION;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.ADDITIONAL_DATA_TYPE_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.EVENT_ADDITIONAL_DATA_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.EVENT_ADDITIONAL_DATA_LENGTH_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.EVENT_CODE_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.EVENT_GROUP_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.FLAGS_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.SEEKER_PUBLIC_ADDRESS_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.TYPE_ACTION_OVER_BLE;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.TYPE_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.TYPE_KEY_BASED_PAIRING_REQUEST;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.VERIFICATION_DATA_INDEX;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.VERIFICATION_DATA_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.FastPairDualConnection.logRetrySuccessEvent;
+import static com.android.server.nearby.common.bluetooth.fastpair.GattConnectionManager.isNoRetryError;
+
+import static com.google.common.base.Verify.verifyNotNull;
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.primitives.Bytes.concat;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.util.Consumer;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothTimeoutException;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AdditionalDataCharacteristic.AdditionalDataType;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.ActionOverBleFlag;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request;
+import com.android.server.nearby.common.bluetooth.fastpair.FastPairConnection.SharedSecret;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection.ChangeObserver;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ErrorCode;
+import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
+
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Handles the handshake step of Fast Pair, the Provider's public address and the shared secret will
+ * be disclosed during this step. It is the first step of all key-based operations, e.g. key-based
+ * pairing and action over BLE.
+ *
+ * @see <a href="https://developers.google.com/nearby/fast-pair/spec#procedure">
+ *     Fastpair Spec Procedure</a>
+ */
+public class HandshakeHandler {
+
+    private static final String TAG = HandshakeHandler.class.getSimpleName();
+    private final GattConnectionManager mGattConnectionManager;
+    private final String mProviderBleAddress;
+    private final Preferences mPreferences;
+    private final EventLoggerWrapper mEventLogger;
+    @Nullable
+    private final FastPairConnection.FastPairSignalChecker mFastPairSignalChecker;
+
+    /**
+     * Keeps the keys used during handshaking, generated by {@link #createKey(byte[])}.
+     */
+    private static final class Keys {
+
+        private final byte[] mSharedSecret;
+        private final byte[] mPublicKey;
+
+        private Keys(byte[] sharedSecret, byte[] publicKey) {
+            this.mSharedSecret = sharedSecret;
+            this.mPublicKey = publicKey;
+        }
+    }
+
+    public HandshakeHandler(
+            GattConnectionManager gattConnectionManager,
+            String bleAddress,
+            Preferences preferences,
+            EventLoggerWrapper eventLogger,
+            @Nullable FastPairConnection.FastPairSignalChecker fastPairSignalChecker) {
+        this.mGattConnectionManager = gattConnectionManager;
+        this.mProviderBleAddress = bleAddress;
+        this.mPreferences = preferences;
+        this.mEventLogger = eventLogger;
+        this.mFastPairSignalChecker = fastPairSignalChecker;
+    }
+
+    /**
+     * Performs a handshake to authenticate and get the remote device's public address. Returns the
+     * AES-128 key as the shared secret for this pairing session.
+     */
+    public SharedSecret doHandshake(byte[] key, HandshakeMessage message)
+            throws GeneralSecurityException, InterruptedException, ExecutionException,
+            TimeoutException, BluetoothException, PairingException {
+        Keys keys = createKey(key);
+        Log.i(TAG,
+                "Handshake " + maskBluetoothAddress(mProviderBleAddress) + ", flags "
+                        + message.mFlags);
+        byte[] handshakeResponse =
+                processGattCommunication(
+                        createPacket(keys, message),
+                        SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+        String providerPublicAddress = decodeResponse(keys.mSharedSecret, handshakeResponse);
+
+        return SharedSecret.create(keys.mSharedSecret, providerPublicAddress);
+    }
+
+    /**
+     * Performs a handshake to authenticate and get the remote device's public address. Returns the
+     * AES-128 key as the shared secret for this pairing session. Will retry and also performs
+     * FastPair signal check if fails.
+     */
+    public SharedSecret doHandshakeWithRetryAndSignalLostCheck(
+            byte[] key, HandshakeMessage message, @Nullable Consumer<Integer> rescueFromError)
+            throws GeneralSecurityException, InterruptedException, ExecutionException,
+            TimeoutException, BluetoothException, PairingException {
+        Keys keys = createKey(key);
+        Log.i(TAG,
+                "Handshake " + maskBluetoothAddress(mProviderBleAddress) + ", flags "
+                        + message.mFlags);
+        int retryCount = 0;
+        byte[] handshakeResponse = null;
+        long startTime = SystemClock.elapsedRealtime();
+        BluetoothException lastException = null;
+        do {
+            try {
+                mEventLogger.setCurrentEvent(EventCode.SECRET_HANDSHAKE_GATT_COMMUNICATION);
+                handshakeResponse =
+                        processGattCommunication(
+                                createPacket(keys, message),
+                                getTimeoutMs(SystemClock.elapsedRealtime() - startTime));
+                mEventLogger.logCurrentEventSucceeded();
+                if (lastException != null) {
+                    logRetrySuccessEvent(EventCode.RECOVER_BY_RETRY_HANDSHAKE, lastException,
+                            mEventLogger);
+                }
+            } catch (BluetoothException e) {
+                lastException = e;
+                long spentTime = SystemClock.elapsedRealtime() - startTime;
+                Log.w(TAG, "Secret handshake failed, address="
+                        + maskBluetoothAddress(mProviderBleAddress)
+                        + ", spent time=" + spentTime + "ms, retryCount=" + retryCount);
+                mEventLogger.logCurrentEventFailed(e);
+
+                if (!mPreferences.getRetryGattConnectionAndSecretHandshake()) {
+                    throw e;
+                }
+
+                if (spentTime > mPreferences.getSecretHandshakeLongTimeoutRetryMaxSpentTimeMs()) {
+                    Log.w(TAG, "Spent too long time for handshake, timeInMs=" + spentTime);
+                    throw e;
+                }
+                if (isNoRetryError(mPreferences, e)) {
+                    throw e;
+                }
+
+                if (mFastPairSignalChecker != null) {
+                    FastPairDualConnection
+                            .checkFastPairSignal(mFastPairSignalChecker, mProviderBleAddress, e);
+                }
+                retryCount++;
+                if (retryCount > mPreferences.getSecretHandshakeRetryAttempts()
+                        || ((e instanceof BluetoothOperationTimeoutException)
+                        && !mPreferences.getRetrySecretHandshakeTimeout())) {
+                    throw new HandshakeException("Fail on handshake!", e);
+                }
+                if (rescueFromError != null) {
+                    rescueFromError.accept(
+                            (e instanceof BluetoothTimeoutException
+                                    || e instanceof BluetoothOperationTimeoutException)
+                                    ? ErrorCode.SUCCESS_RETRY_SECRET_HANDSHAKE_TIMEOUT
+                                    : ErrorCode.SUCCESS_RETRY_SECRET_HANDSHAKE_ERROR);
+                }
+            }
+        } while (mPreferences.getRetryGattConnectionAndSecretHandshake()
+                && handshakeResponse == null);
+        if (retryCount > 0) {
+            Log.i(TAG, "Secret handshake failed but restored by retry, retry count=" + retryCount);
+        }
+        String providerPublicAddress =
+                decodeResponse(keys.mSharedSecret, verifyNotNull(handshakeResponse));
+
+        return SharedSecret.create(keys.mSharedSecret, providerPublicAddress);
+    }
+
+    @VisibleForTesting
+    long getTimeoutMs(long spentTime) {
+        if (!mPreferences.getRetryGattConnectionAndSecretHandshake()) {
+            return SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds());
+        } else {
+            return spentTime < mPreferences.getSecretHandshakeShortTimeoutRetryMaxSpentTimeMs()
+                    ? mPreferences.getSecretHandshakeShortTimeoutMs()
+                    : mPreferences.getSecretHandshakeLongTimeoutMs();
+        }
+    }
+
+    /**
+     * If the given key is an ecc-256 public key (currently, we are using secp256r1), the shared
+     * secret is generated by ECDH; if the input key is AES-128 key (should be the account key),
+     * then it is the shared secret.
+     */
+    private Keys createKey(byte[] key) throws GeneralSecurityException {
+        if (key.length == EllipticCurveDiffieHellmanExchange.PUBLIC_KEY_LENGTH) {
+            EllipticCurveDiffieHellmanExchange exchange = EllipticCurveDiffieHellmanExchange
+                    .create();
+            byte[] publicKey = exchange.getPublicKey();
+            if (publicKey != null) {
+                Log.i(TAG, "Handshake " + maskBluetoothAddress(mProviderBleAddress)
+                        + ", generates key by ECDH.");
+            } else {
+                throw new GeneralSecurityException("Failed to do ECDH.");
+            }
+            return new Keys(exchange.generateSecret(key), publicKey);
+        } else if (key.length == AesEcbSingleBlockEncryption.KEY_LENGTH) {
+            Log.i(TAG, "Handshake " + maskBluetoothAddress(mProviderBleAddress)
+                    + ", using the given secret.");
+            return new Keys(key, new byte[0]);
+        } else {
+            throw new GeneralSecurityException("Key length is not correct: " + key.length);
+        }
+    }
+
+    private static byte[] createPacket(Keys keys, HandshakeMessage message)
+            throws GeneralSecurityException {
+        byte[] encryptedMessage = encrypt(keys.mSharedSecret, message.getBytes());
+        return concat(encryptedMessage, keys.mPublicKey);
+    }
+
+    private byte[] processGattCommunication(byte[] packet, long gattOperationTimeoutMS)
+            throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+        BluetoothGattConnection gattConnection = mGattConnectionManager.getConnection();
+        gattConnection.setOperationTimeout(gattOperationTimeoutMS);
+        UUID characteristicUuid = KeyBasedPairingCharacteristic.getId(gattConnection);
+        ChangeObserver changeObserver =
+                gattConnection.enableNotification(FastPairService.ID, characteristicUuid);
+
+        Log.i(TAG,
+                "Writing handshake packet to address=" + maskBluetoothAddress(mProviderBleAddress));
+        gattConnection.writeCharacteristic(FastPairService.ID, characteristicUuid, packet);
+        Log.i(TAG, "Waiting handshake packet from address=" + maskBluetoothAddress(
+                mProviderBleAddress));
+        return changeObserver.waitForUpdate(gattOperationTimeoutMS);
+    }
+
+    private String decodeResponse(byte[] sharedSecret, byte[] response)
+            throws PairingException, GeneralSecurityException {
+        if (response.length != AES_BLOCK_LENGTH) {
+            throw new PairingException(
+                    "Handshake failed because of incorrect response: " + base16().encode(response));
+        }
+        // 1 byte type, 6 bytes public address, remainder random salt.
+        byte[] decryptedResponse = decrypt(sharedSecret, response);
+        if (decryptedResponse[0] != KeyBasedPairingCharacteristic.Response.TYPE) {
+            throw new PairingException(
+                    "Handshake response type incorrect: " + decryptedResponse[0]);
+        }
+        String address = BluetoothAddress.encode(Arrays.copyOfRange(decryptedResponse, 1, 7));
+        Log.i(TAG, "Handshake success with public " + maskBluetoothAddress(address) + ", ble "
+                + maskBluetoothAddress(mProviderBleAddress));
+        return address;
+    }
+
+    /**
+     * The base class for handshake message that contains the common data: message type, flags and
+     * verification data.
+     */
+    abstract static class HandshakeMessage {
+
+        final byte mType;
+        final byte mFlags;
+        private final byte[] mVerificationData;
+
+        HandshakeMessage(Builder<?> builder) {
+            this.mType = builder.mType;
+            this.mVerificationData = builder.mVerificationData;
+            this.mFlags = builder.mFlags;
+        }
+
+        abstract static class Builder<T extends Builder<T>> {
+
+            byte mType;
+            byte mFlags;
+            private byte[] mVerificationData;
+
+            abstract T getThis();
+
+            T setVerificationData(byte[] verificationData) {
+                if (verificationData.length != BLUETOOTH_ADDRESS_LENGTH) {
+                    throw new IllegalArgumentException(
+                            "Incorrect verification data length: " + verificationData.length + ".");
+                }
+                this.mVerificationData = verificationData;
+                return getThis();
+            }
+        }
+
+        /**
+         * Constructs the base handshake message according to the format of Fast Pair spec.
+         */
+        byte[] constructBaseBytes() {
+            byte[] rawMessage = new byte[Request.SIZE];
+            new SecureRandom().nextBytes(rawMessage);
+            rawMessage[TYPE_INDEX] = mType;
+            rawMessage[FLAGS_INDEX] = mFlags;
+
+            System.arraycopy(
+                    mVerificationData,
+                    /* srcPos= */ 0,
+                    rawMessage,
+                    VERIFICATION_DATA_INDEX,
+                    VERIFICATION_DATA_LENGTH);
+            return rawMessage;
+        }
+
+        /**
+         * Returns the raw handshake message.
+         */
+        abstract byte[] getBytes();
+    }
+
+    /**
+     * Extends {@link HandshakeMessage} and contains the required data for key-based pairing
+     * request.
+     */
+    public static class KeyBasedPairingRequest extends HandshakeMessage {
+
+        @Nullable
+        private final byte[] mSeekerPublicAddress;
+
+        private KeyBasedPairingRequest(Builder builder) {
+            super(builder);
+            this.mSeekerPublicAddress = builder.mSeekerPublicAddress;
+        }
+
+        @Override
+        byte[] getBytes() {
+            byte[] rawMessage = constructBaseBytes();
+            if (mSeekerPublicAddress != null) {
+                System.arraycopy(
+                        mSeekerPublicAddress,
+                        /* srcPos= */ 0,
+                        rawMessage,
+                        SEEKER_PUBLIC_ADDRESS_INDEX,
+                        BLUETOOTH_ADDRESS_LENGTH);
+            }
+            Log.i(TAG,
+                    "Handshake Message: type (" + rawMessage[TYPE_INDEX] + "), flag ("
+                            + rawMessage[FLAGS_INDEX] + ").");
+            return rawMessage;
+        }
+
+        /**
+         * Builder class for key-based pairing request.
+         */
+        public static class Builder extends HandshakeMessage.Builder<Builder> {
+
+            @Nullable
+            private byte[] mSeekerPublicAddress;
+
+            /**
+             * Adds flags without changing other flags.
+             */
+            public Builder addFlag(@KeyBasedPairingRequestFlag int flag) {
+                this.mFlags |= (byte) flag;
+                return this;
+            }
+
+            /**
+             * Set seeker's public address.
+             */
+            public Builder setSeekerPublicAddress(byte[] seekerPublicAddress) {
+                this.mSeekerPublicAddress = seekerPublicAddress;
+                return this;
+            }
+
+            /**
+             * Buulds KeyBasedPairigRequest.
+             */
+            public KeyBasedPairingRequest build() {
+                mType = TYPE_KEY_BASED_PAIRING_REQUEST;
+                return new KeyBasedPairingRequest(this);
+            }
+
+            @Override
+            Builder getThis() {
+                return this;
+            }
+        }
+    }
+
+    /**
+     * Extends {@link HandshakeMessage} and contains the required data for action over BLE request.
+     */
+    public static class ActionOverBle extends HandshakeMessage {
+
+        private final byte mEventGroup;
+        private final byte mEventCode;
+        @Nullable
+        private final byte[] mEventData;
+        private final byte mAdditionalDataType;
+
+        private ActionOverBle(Builder builder) {
+            super(builder);
+            this.mEventGroup = builder.mEventGroup;
+            this.mEventCode = builder.mEventCode;
+            this.mEventData = builder.mEventData;
+            this.mAdditionalDataType = builder.mAdditionalDataType;
+        }
+
+        @Override
+        byte[] getBytes() {
+            byte[] rawMessage = constructBaseBytes();
+            StringBuilder stringBuilder =
+                    new StringBuilder(
+                            String.format(
+                                    "type (%02X), flag (%02X)", rawMessage[TYPE_INDEX],
+                                    rawMessage[FLAGS_INDEX]));
+            if ((mFlags & (byte) DEVICE_ACTION) != 0) {
+                rawMessage[EVENT_GROUP_INDEX] = mEventGroup;
+                rawMessage[EVENT_CODE_INDEX] = mEventCode;
+
+                if (mEventData != null) {
+                    rawMessage[EVENT_ADDITIONAL_DATA_LENGTH_INDEX] = (byte) mEventData.length;
+                    System.arraycopy(
+                            mEventData,
+                            /* srcPos= */ 0,
+                            rawMessage,
+                            EVENT_ADDITIONAL_DATA_INDEX,
+                            mEventData.length);
+                } else {
+                    rawMessage[EVENT_ADDITIONAL_DATA_LENGTH_INDEX] = (byte) 0;
+                }
+                stringBuilder.append(
+                        String.format(
+                                ", group(%02X), code(%02X), length(%02X)",
+                                rawMessage[EVENT_GROUP_INDEX],
+                                rawMessage[EVENT_CODE_INDEX],
+                                rawMessage[EVENT_ADDITIONAL_DATA_LENGTH_INDEX]));
+            }
+            if ((mFlags & (byte) ADDITIONAL_DATA_CHARACTERISTIC) != 0) {
+                rawMessage[ADDITIONAL_DATA_TYPE_INDEX] = mAdditionalDataType;
+                stringBuilder.append(
+                        String.format(", data id(%02X)", rawMessage[ADDITIONAL_DATA_TYPE_INDEX]));
+            }
+            Log.i(TAG, "Handshake Message: " + stringBuilder);
+            return rawMessage;
+        }
+
+        /**
+         * Builder class for action over BLE request.
+         */
+        public static class Builder extends HandshakeMessage.Builder<Builder> {
+
+            private byte mEventGroup;
+            private byte mEventCode;
+            @Nullable
+            private byte[] mEventData;
+            private byte mAdditionalDataType;
+
+            // Adds a flag to this handshake message. This can be called repeatedly for adding
+            // different preference.
+
+            /**
+             * Adds flag without changing other flags.
+             */
+            public Builder addFlag(@ActionOverBleFlag int flag) {
+                this.mFlags |= (byte) flag;
+                return this;
+            }
+
+            /**
+             * Set event group and event code.
+             */
+            public Builder setEvent(int eventGroup, int eventCode) {
+                this.mFlags |= (byte) DEVICE_ACTION;
+                this.mEventGroup = (byte) (eventGroup & 0xFF);
+                this.mEventCode = (byte) (eventCode & 0xFF);
+                return this;
+            }
+
+            /**
+             * Set event additional data.
+             */
+            public Builder setEventAdditionalData(byte[] data) {
+                this.mEventData = data;
+                return this;
+            }
+
+            /**
+             * Set event additional data type.
+             */
+            public Builder setAdditionalDataType(@AdditionalDataType int additionalDataType) {
+                this.mFlags |= (byte) ADDITIONAL_DATA_CHARACTERISTIC;
+                this.mAdditionalDataType = (byte) additionalDataType;
+                return this;
+            }
+
+            @Override
+            Builder getThis() {
+                return this;
+            }
+
+            ActionOverBle build() {
+                mType = TYPE_ACTION_OVER_BLE;
+                return new ActionOverBle(this);
+            }
+        }
+    }
+
+    /**
+     * Exception for handshake failure.
+     */
+    public static class HandshakeException extends PairingException {
+
+        private final BluetoothException mOriginalException;
+
+        @VisibleForTesting
+        HandshakeException(String format, BluetoothException e) {
+            super(format);
+            mOriginalException = e;
+        }
+
+        public BluetoothException getOriginalException() {
+            return mOriginalException;
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HeadsetPiece.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HeadsetPiece.java
new file mode 100644
index 0000000..26ff79f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HeadsetPiece.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.FileProvider;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * This class is subclass of real headset. It contains image url, battery value and charging
+ * status.
+ */
+public class HeadsetPiece implements Parcelable {
+    private int mLowLevelThreshold;
+    private int mBatteryLevel;
+    private String mImageUrl;
+    private boolean mCharging;
+    private Uri mImageContentUri;
+
+    private HeadsetPiece(
+            int lowLevelThreshold,
+            int batteryLevel,
+            String imageUrl,
+            boolean charging,
+            @Nullable Uri imageContentUri) {
+        this.mLowLevelThreshold = lowLevelThreshold;
+        this.mBatteryLevel = batteryLevel;
+        this.mImageUrl = imageUrl;
+        this.mCharging = charging;
+        this.mImageContentUri = imageContentUri;
+    }
+
+    /**
+     * Returns a builder of HeadsetPiece.
+     */
+    public static HeadsetPiece.Builder builder() {
+        return new HeadsetPiece.Builder();
+    }
+
+    /**
+     * The low level threshold.
+     */
+    public int lowLevelThreshold() {
+        return mLowLevelThreshold;
+    }
+
+    /**
+     * The battery level.
+     */
+    public int batteryLevel() {
+        return mBatteryLevel;
+    }
+
+    /**
+     * The web URL of the image.
+     */
+    public String imageUrl() {
+        return mImageUrl;
+    }
+
+    /**
+     * Whether the headset is charging.
+     */
+    public boolean charging() {
+        return mCharging;
+    }
+
+    /**
+     * The content Uri of the image if it could be downloaded from the web URL and generated through
+     * {@link FileProvider#getUriForFile} successfully, otherwise null.
+     */
+    @Nullable
+    public Uri imageContentUri() {
+        return mImageContentUri;
+    }
+
+    /**
+     * @return whether battery is low or not.
+     */
+    public boolean isBatteryLow() {
+        return batteryLevel() <= lowLevelThreshold() && batteryLevel() >= 0 && !charging();
+    }
+
+    @Override
+    public String toString() {
+        return "HeadsetPiece{"
+                + "lowLevelThreshold=" + mLowLevelThreshold + ", "
+                + "batteryLevel=" + mBatteryLevel + ", "
+                + "imageUrl=" + mImageUrl + ", "
+                + "charging=" + mCharging + ", "
+                + "imageContentUri=" + mImageContentUri
+                + "}";
+    }
+
+    /**
+     * Builder function for headset piece.
+     */
+    public static class Builder {
+        private int mLowLevelThreshold;
+        private int mBatteryLevel;
+        private String mImageUrl;
+        private boolean mCharging;
+        private Uri mImageContentUri;
+
+        /**
+         * Set low level threshold.
+         */
+        public HeadsetPiece.Builder setLowLevelThreshold(int lowLevelThreshold) {
+            this.mLowLevelThreshold = lowLevelThreshold;
+            return this;
+        }
+
+        /**
+         * Set battery level.
+         */
+        public HeadsetPiece.Builder setBatteryLevel(int level) {
+            this.mBatteryLevel = level;
+            return this;
+        }
+
+        /**
+         * Set image url.
+         */
+        public HeadsetPiece.Builder setImageUrl(String url) {
+            this.mImageUrl = url;
+            return this;
+        }
+
+        /**
+         * Set charging.
+         */
+        public HeadsetPiece.Builder setCharging(boolean charging) {
+            this.mCharging = charging;
+            return this;
+        }
+
+        /**
+         * Set image content Uri.
+         */
+        public HeadsetPiece.Builder setImageContentUri(Uri uri) {
+            this.mImageContentUri = uri;
+            return this;
+        }
+
+        /**
+         * Builds HeadSetPiece.
+         */
+        public HeadsetPiece build() {
+            return new HeadsetPiece(mLowLevelThreshold, mBatteryLevel, mImageUrl, mCharging,
+                    mImageContentUri);
+        }
+    }
+
+    @Override
+    public final void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(imageUrl());
+        dest.writeInt(lowLevelThreshold());
+        dest.writeInt(batteryLevel());
+        // Writes 1 if charging, otherwise 0.
+        dest.writeByte((byte) (charging() ? 1 : 0));
+        dest.writeParcelable(imageContentUri(), flags);
+    }
+
+    @Override
+    public final int describeContents() {
+        return 0;
+    }
+
+    public static final Creator<HeadsetPiece> CREATOR =
+            new Creator<HeadsetPiece>() {
+                @Override
+                public HeadsetPiece createFromParcel(Parcel in) {
+                    String imageUrl = in.readString();
+                    return HeadsetPiece.builder()
+                            .setImageUrl(imageUrl != null ? imageUrl : "")
+                            .setLowLevelThreshold(in.readInt())
+                            .setBatteryLevel(in.readInt())
+                            .setCharging(in.readByte() != 0)
+                            .setImageContentUri(in.readParcelable(Uri.class.getClassLoader()))
+                            .build();
+                }
+
+                @Override
+                public HeadsetPiece[] newArray(int size) {
+                    return new HeadsetPiece[size];
+                }
+            };
+
+    @Override
+    public final int hashCode() {
+        return Arrays.hashCode(
+                new Object[]{
+                        lowLevelThreshold(), batteryLevel(), imageUrl(), charging(),
+                        imageContentUri()
+                });
+    }
+
+    @Override
+    public final boolean equals(@Nullable Object other) {
+        if (other == null) {
+            return false;
+        }
+
+        if (this == other) {
+            return true;
+        }
+
+        if (!(other instanceof HeadsetPiece)) {
+            return false;
+        }
+
+        HeadsetPiece that = (HeadsetPiece) other;
+        return lowLevelThreshold() == that.lowLevelThreshold()
+                && batteryLevel() == that.batteryLevel()
+                && Objects.equals(imageUrl(), that.imageUrl())
+                && charging() == that.charging()
+                && Objects.equals(imageContentUri(), that.imageContentUri());
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HmacSha256.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HmacSha256.java
new file mode 100644
index 0000000..cc7a300
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/HmacSha256.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.KEY_LENGTH;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.security.GeneralSecurityException;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * HMAC-SHA256 utility used to generate key-SHA256 based message authentication code. This is
+ * specific for Fast Pair GATT connection exchanging data to verify both the data integrity and the
+ * authentication of a message. It is defined as:
+ *
+ * <ol>
+ *   <li>SHA256(concat((key ^ opad),SHA256(concat((key ^ ipad), data)))), where
+ *   <li>key is the given secret extended to 64 bytes by concat(secret, ZEROS).
+ *   <li>opad is 64 bytes outer padding, consisting of repeated bytes valued 0x5c.
+ *   <li>ipad is 64 bytes inner padding, consisting of repeated bytes valued 0x36.
+ * </ol>
+ *
+ */
+final class HmacSha256 {
+    @VisibleForTesting static final int HMAC_SHA256_BLOCK_SIZE = 64;
+
+    private HmacSha256() {}
+
+    /**
+     * Generates the HMAC for given parameters, this is specific for Fast Pair GATT connection
+     * exchanging data which is encrypted using AES-CTR.
+     *
+     * @param secret 16 bytes shared secret.
+     * @param data the data encrypted using AES-CTR and the given nonce.
+     * @return HMAC-SHA256 result.
+     */
+    static byte[] build(byte[] secret, byte[] data) throws GeneralSecurityException {
+        // Currently we only accept AES-128 key here, the second check is to secure we won't
+        // modify KEY_LENGTH to > HMAC_SHA256_BLOCK_SIZE by mistake.
+        if (secret.length != KEY_LENGTH) {
+            throw new GeneralSecurityException("Incorrect key length, should be the AES-128 key.");
+        }
+        if (KEY_LENGTH > HMAC_SHA256_BLOCK_SIZE) {
+            throw new GeneralSecurityException("KEY_LENGTH > HMAC_SHA256_BLOCK_SIZE!");
+        }
+
+        return buildWith64BytesKey(secret, data);
+    }
+
+    /**
+     * Generates the HMAC for given parameters, this is specific for Fast Pair GATT connection
+     * exchanging data which is encrypted using AES-CTR.
+     *
+     * @param secret 16 bytes shared secret.
+     * @param data the data encrypted using AES-CTR and the given nonce.
+     * @return HMAC-SHA256 result.
+     */
+    static byte[] buildWith64BytesKey(byte[] secret, byte[] data) throws GeneralSecurityException {
+        if (secret.length > HMAC_SHA256_BLOCK_SIZE) {
+            throw new GeneralSecurityException("KEY_LENGTH > HMAC_SHA256_BLOCK_SIZE!");
+        }
+
+        Mac mac = Mac.getInstance("HmacSHA256");
+        SecretKeySpec keySpec = new SecretKeySpec(secret, "HmacSHA256");
+        mac.init(keySpec);
+
+        return mac.doFinal(data);
+    }
+
+    /**
+     * Constant-time HMAC comparison to prevent a possible timing attack, e.g. time the same MAC
+     * with all different first byte for a given ciphertext, the right one will take longer as it
+     * will fail on the second byte's verification.
+     *
+     * @param hmac1 HMAC want to be compared with.
+     * @param hmac2 HMAC want to be compared with.
+     * @return true if and ony if the give 2 HMACs are identical and non-null.
+     */
+    static boolean compareTwoHMACs(byte[] hmac1, byte[] hmac2) {
+        if (hmac1 == null || hmac2 == null) {
+            return false;
+        }
+
+        if (hmac1.length != hmac2.length) {
+            return false;
+        }
+        // This is for constant-time comparison, don't optimize it.
+        int res = 0;
+        for (int i = 0; i < hmac1.length; i++) {
+            res |= hmac1[i] ^ hmac2[i];
+        }
+        return res == 0;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Ltv.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Ltv.java
new file mode 100644
index 0000000..88c9484
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Ltv.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import com.google.common.primitives.Bytes;
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A length, type, value (LTV) data block.
+ */
+public class Ltv {
+
+    private static final int SIZE_OF_LEN_TYPE = 2;
+
+    final byte mType;
+    final byte[] mValue;
+
+    /**
+     * Thrown if there's an error during {@link #parse}.
+     */
+    public static class ParseException extends Exception {
+
+        @FormatMethod
+        private ParseException(@FormatString String format, Object... objects) {
+            super(String.format(format, objects));
+        }
+    }
+
+    /**
+     * Constructor.
+     */
+    public Ltv(byte type, byte... value) {
+        this.mType = type;
+        this.mValue = value;
+    }
+
+    /**
+     * Parses a list of LTV blocks out of the input byte block.
+     */
+    static List<Ltv> parse(byte[] bytes) throws ParseException {
+        List<Ltv> ltvs = new ArrayList<>();
+        // The "+ 2" is for the length and type bytes.
+        for (int valueLength, i = 0; i < bytes.length; i += SIZE_OF_LEN_TYPE + valueLength) {
+            // - 1 since the length in the packet includes the type byte.
+            valueLength = bytes[i] - 1;
+            if (valueLength < 0 || bytes.length < i + SIZE_OF_LEN_TYPE + valueLength) {
+                throw new ParseException(
+                        "Wrong length=%d at index=%d in LTVs=%s", bytes[i], i,
+                        base16().encode(bytes));
+            }
+            ltvs.add(new Ltv(bytes[i + 1], Arrays.copyOfRange(bytes, i + SIZE_OF_LEN_TYPE,
+                    i + SIZE_OF_LEN_TYPE + valueLength)));
+        }
+        return ltvs;
+    }
+
+    /**
+     * Returns an LTV block, where length is mValue.length + 1 (for the type byte).
+     */
+    public byte[] getBytes() {
+        return Bytes.concat(new byte[]{(byte) (mValue.length + 1), mType}, mValue);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/MessageStreamHmacEncoder.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/MessageStreamHmacEncoder.java
new file mode 100644
index 0000000..b04cf73
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/MessageStreamHmacEncoder.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.generateNonce;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+/**
+ * Message stream utilities for encoding raw packet with HMAC.
+ *
+ * <p>Encoded packet is:
+ *
+ * <ol>
+ *   <li>Packet[0 - (data length - 1)]: the raw data.
+ *   <li>Packet[data length - (data length + 7)]: the 8-byte message nonce.
+ *   <li>Packet[(data length + 8) - (data length + 15)]: the 8-byte of HMAC.
+ * </ol>
+ */
+public class MessageStreamHmacEncoder {
+    public static final int EXTRACT_HMAC_SIZE = 8;
+    public static final int SECTION_NONCE_LENGTH = 8;
+
+    private MessageStreamHmacEncoder() {}
+
+    /** Encodes Message Packet. */
+    public static byte[] encodeMessagePacket(byte[] accountKey, byte[] sectionNonce, byte[] data)
+            throws GeneralSecurityException {
+        checkAccountKeyAndSectionNonce(accountKey, sectionNonce);
+
+        if (data == null || data.length == 0) {
+            throw new GeneralSecurityException("No input data for encodeMessagePacket");
+        }
+
+        byte[] messageNonce = generateNonce();
+        byte[] extractedHmac =
+                Arrays.copyOf(
+                        HmacSha256.buildWith64BytesKey(
+                                accountKey, concat(sectionNonce, messageNonce, data)),
+                        EXTRACT_HMAC_SIZE);
+
+        return concat(data, messageNonce, extractedHmac);
+    }
+
+    /** Verifies Hmac. */
+    public static boolean verifyHmac(byte[] accountKey, byte[] sectionNonce, byte[] data)
+            throws GeneralSecurityException {
+        checkAccountKeyAndSectionNonce(accountKey, sectionNonce);
+        if (data == null) {
+            throw new GeneralSecurityException("data is null");
+        }
+        if (data.length <= EXTRACT_HMAC_SIZE + SECTION_NONCE_LENGTH) {
+            throw new GeneralSecurityException("data.length too short");
+        }
+
+        byte[] hmac = Arrays.copyOfRange(data, data.length - EXTRACT_HMAC_SIZE, data.length);
+        byte[] messageNonce =
+                Arrays.copyOfRange(
+                        data,
+                        data.length - EXTRACT_HMAC_SIZE - SECTION_NONCE_LENGTH,
+                        data.length - EXTRACT_HMAC_SIZE);
+        byte[] rawData = Arrays.copyOf(
+                data, data.length - EXTRACT_HMAC_SIZE - SECTION_NONCE_LENGTH);
+        return Arrays.equals(
+                Arrays.copyOf(
+                        HmacSha256.buildWith64BytesKey(
+                                accountKey, concat(sectionNonce, messageNonce, rawData)),
+                        EXTRACT_HMAC_SIZE),
+                hmac);
+    }
+
+    private static void checkAccountKeyAndSectionNonce(byte[] accountKey, byte[] sectionNonce)
+            throws GeneralSecurityException {
+        if (accountKey == null || accountKey.length == 0) {
+            throw new GeneralSecurityException(
+                    "Incorrect accountKey for encoding message packet, accountKey.length = "
+                            + (accountKey == null ? "NULL" : accountKey.length));
+        }
+
+        if (sectionNonce == null || sectionNonce.length != SECTION_NONCE_LENGTH) {
+            throw new GeneralSecurityException(
+                    "Incorrect sectionNonce for encoding message packet, sectionNonce.length = "
+                            + (sectionNonce == null ? "NULL" : sectionNonce.length));
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/NamingEncoder.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/NamingEncoder.java
new file mode 100644
index 0000000..1521be6
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/NamingEncoder.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.NONCE_SIZE;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.annotation.TargetApi;
+import android.os.Build.VERSION_CODES;
+
+import com.google.common.base.Utf8;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+/**
+ * Naming utilities for encoding naming packet, decoding naming packet and verifying both the data
+ * integrity and the authentication of a message by checking HMAC.
+ *
+ * <p>Naming packet is:
+ *
+ * <ol>
+ *   <li>Naming_Packet[0 - 7]: the first 8-byte of HMAC.
+ *   <li>Naming_Packet[8 - var]: the encrypted name (with 8-byte nonce appended to the front).
+ * </ol>
+ */
+@TargetApi(VERSION_CODES.M)
+public final class NamingEncoder {
+
+    static final int EXTRACT_HMAC_SIZE = 8;
+    static final int MAX_LENGTH_OF_NAME = 48;
+
+    private NamingEncoder() {
+    }
+
+    /**
+     * Encodes the name to naming packet by the given secret.
+     *
+     * @param secret AES-128 key for encryption.
+     * @param name the given name to be encoded.
+     * @return the encrypted data with the 8-byte extracted HMAC appended to the front.
+     * @throws GeneralSecurityException if the given key or name is invalid for encoding.
+     */
+    public static byte[] encodeNamingPacket(byte[] secret, String name)
+            throws GeneralSecurityException {
+        if (secret == null || secret.length != AesCtrMultipleBlockEncryption.KEY_LENGTH) {
+            throw new GeneralSecurityException(
+                    "Incorrect secret for encoding name packet, secret.length = "
+                            + (secret == null ? "NULL" : secret.length));
+        }
+
+        if ((name == null) || (name.length() == 0) || (Utf8.encodedLength(name)
+                > MAX_LENGTH_OF_NAME)) {
+            throw new GeneralSecurityException(
+                    "Invalid name for encoding name packet, Utf8.encodedLength(name) = "
+                            + (name == null ? "NULL" : Utf8.encodedLength(name)));
+        }
+
+        byte[] encryptedData = AesCtrMultipleBlockEncryption.encrypt(secret, name.getBytes(UTF_8));
+        byte[] extractedHmac =
+                Arrays.copyOf(HmacSha256.build(secret, encryptedData), EXTRACT_HMAC_SIZE);
+
+        return concat(extractedHmac, encryptedData);
+    }
+
+    /**
+     * Decodes the name from naming packet by the given secret.
+     *
+     * @param secret AES-128 key used in the encryption to decrypt data.
+     * @param namingPacket naming packet which is encoded by the given secret..
+     * @return the name decoded from the given packet.
+     * @throws GeneralSecurityException if the given key or naming packet is invalid for decoding.
+     */
+    public static String decodeNamingPacket(byte[] secret, byte[] namingPacket)
+            throws GeneralSecurityException {
+        if (secret == null || secret.length != AesCtrMultipleBlockEncryption.KEY_LENGTH) {
+            throw new GeneralSecurityException(
+                    "Incorrect secret for decoding name packet, secret.length = "
+                            + (secret == null ? "NULL" : secret.length));
+        }
+        if (namingPacket == null
+                || namingPacket.length <= EXTRACT_HMAC_SIZE
+                || namingPacket.length > (MAX_LENGTH_OF_NAME + EXTRACT_HMAC_SIZE + NONCE_SIZE)) {
+            throw new GeneralSecurityException(
+                    "Naming packet size is incorrect, namingPacket.length is "
+                            + (namingPacket == null ? "NULL" : namingPacket.length));
+        }
+
+        if (!verifyHmac(secret, namingPacket)) {
+            throw new GeneralSecurityException(
+                    "Verify HMAC failed, could be incorrect key or naming packet.");
+        }
+        byte[] encryptedData = Arrays
+                .copyOfRange(namingPacket, EXTRACT_HMAC_SIZE, namingPacket.length);
+        return new String(AesCtrMultipleBlockEncryption.decrypt(secret, encryptedData), UTF_8);
+    }
+
+    // Computes the HMAC of the given key and name, and compares the first 8-byte of the HMAC result
+    // with the one from name packet. Must call constant-time comparison to prevent a possible
+    // timing attack, e.g. time the same MAC with all different first byte for a given ciphertext,
+    // the right one will take longer as it will fail on the second byte's verification.
+    private static boolean verifyHmac(byte[] key, byte[] namingPacket)
+            throws GeneralSecurityException {
+        byte[] packetHmac = Arrays.copyOfRange(namingPacket, /* from= */ 0, EXTRACT_HMAC_SIZE);
+        byte[] encryptedData = Arrays
+                .copyOfRange(namingPacket, EXTRACT_HMAC_SIZE, namingPacket.length);
+        byte[] computedHmac = Arrays
+                .copyOf(HmacSha256.build(key, encryptedData), EXTRACT_HMAC_SIZE);
+
+        return HmacSha256.compareTwoHMACs(packetHmac, computedHmac);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PairingException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PairingException.java
new file mode 100644
index 0000000..722dc85
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PairingException.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+/** Base class for pairing exceptions. */
+// TODO(b/200594968): convert exceptions into error codes to save memory.
+public class PairingException extends Exception {
+    PairingException(String format, Object... objects) {
+        super(String.format(format, objects));
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PairingProgressListener.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PairingProgressListener.java
new file mode 100644
index 0000000..270cb42
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PairingProgressListener.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import androidx.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** Callback interface for pairing progress. */
+public interface PairingProgressListener {
+
+    /** Fast Pair Bond State. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    PairingEvent.START,
+                    PairingEvent.SUCCESS,
+                    PairingEvent.FAILED,
+                    PairingEvent.UNKNOWN,
+            })
+    public @interface PairingEvent {
+        int START = 0;
+        int SUCCESS = 1;
+        int FAILED = 2;
+        int UNKNOWN = 3;
+    }
+
+    /** Returns enum based on the ordinal index. */
+    static @PairingEvent int fromOrdinal(int ordinal) {
+        switch (ordinal) {
+            case 0:
+                return PairingEvent.START;
+            case 1:
+                return PairingEvent.SUCCESS;
+            case 2:
+                return PairingEvent.FAILED;
+            case 3:
+                return PairingEvent.UNKNOWN;
+            default:
+                return PairingEvent.UNKNOWN;
+        }
+    }
+
+    /** Callback function upon pairing progress update. */
+    void onPairingProgressUpdating(@PairingEvent int event, String message);
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PasskeyConfirmationHandler.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PasskeyConfirmationHandler.java
new file mode 100644
index 0000000..f5807a3
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/PasskeyConfirmationHandler.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.bluetooth.BluetoothDevice;
+
+/** Interface for getting the passkey confirmation request. */
+public interface PasskeyConfirmationHandler {
+    /** Called when getting the passkey confirmation request while pairing. */
+    void onPasskeyConfirmation(BluetoothDevice device, int passkey);
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Preferences.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Preferences.java
new file mode 100644
index 0000000..bb7b71b
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Preferences.java
@@ -0,0 +1,2309 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.get16BitUuid;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.FirmwareVersionCharacteristic;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Shorts;
+
+import java.nio.ByteOrder;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Preferences that tweak the Fast Pairing process: timeouts, number of retries... All preferences
+ * have default values which should be reasonable for all clients.
+ */
+public class Preferences {
+
+    private final int mGattOperationTimeoutSeconds;
+    private final int mGattConnectionTimeoutSeconds;
+    private final int mBluetoothToggleTimeoutSeconds;
+    private final int mBluetoothToggleSleepSeconds;
+    private final int mClassicDiscoveryTimeoutSeconds;
+    private final int mNumDiscoverAttempts;
+    private final int mDiscoveryRetrySleepSeconds;
+    private final boolean mIgnoreDiscoveryError;
+    private final int mSdpTimeoutSeconds;
+    private final int mNumSdpAttempts;
+    private final int mNumCreateBondAttempts;
+    private final int mNumConnectAttempts;
+    private final int mNumWriteAccountKeyAttempts;
+    private final boolean mToggleBluetoothOnFailure;
+    private final boolean mBluetoothStateUsesPolling;
+    private final int mBluetoothStatePollingMillis;
+    private final int mNumAttempts;
+    private final boolean mEnableBrEdrHandover;
+    private final short mBrHandoverDataCharacteristicId;
+    private final short mBluetoothSigDataCharacteristicId;
+    private final short mFirmwareVersionCharacteristicId;
+    private final short mBrTransportBlockDataDescriptorId;
+    private final boolean mWaitForUuidsAfterBonding;
+    private final boolean mReceiveUuidsAndBondedEventBeforeClose;
+    private final int mRemoveBondTimeoutSeconds;
+    private final int mRemoveBondSleepMillis;
+    private final int mCreateBondTimeoutSeconds;
+    private final int mHidCreateBondTimeoutSeconds;
+    private final int mProxyTimeoutSeconds;
+    private final boolean mRejectPhonebookAccess;
+    private final boolean mRejectMessageAccess;
+    private final boolean mRejectSimAccess;
+    private final int mWriteAccountKeySleepMillis;
+    private final boolean mSkipDisconnectingGattBeforeWritingAccountKey;
+    private final boolean mMoreEventLogForQuality;
+    private final boolean mRetryGattConnectionAndSecretHandshake;
+    private final long mGattConnectShortTimeoutMs;
+    private final long mGattConnectLongTimeoutMs;
+    private final long mGattConnectShortTimeoutRetryMaxSpentTimeMs;
+    private final long mAddressRotateRetryMaxSpentTimeMs;
+    private final long mPairingRetryDelayMs;
+    private final long mSecretHandshakeShortTimeoutMs;
+    private final long mSecretHandshakeLongTimeoutMs;
+    private final long mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs;
+    private final long mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs;
+    private final long mSecretHandshakeRetryAttempts;
+    private final long mSecretHandshakeRetryGattConnectionMaxSpentTimeMs;
+    private final long mSignalLostRetryMaxSpentTimeMs;
+    private final ImmutableSet<Integer> mGattConnectionAndSecretHandshakeNoRetryGattError;
+    private final boolean mRetrySecretHandshakeTimeout;
+    private final boolean mLogUserManualRetry;
+    private final int mPairFailureCounts;
+    private final String mCachedDeviceAddress;
+    private final String mPossibleCachedDeviceAddress;
+    private final int mSameModelIdPairedDeviceCount;
+    private final boolean mIsDeviceFinishCheckAddressFromCache;
+    private final boolean mLogPairWithCachedModelId;
+    private final boolean mDirectConnectProfileIfModelIdInCache;
+    private final boolean mAcceptPasskey;
+    private final byte[] mSupportedProfileUuids;
+    private final boolean mProviderInitiatesBondingIfSupported;
+    private final boolean mAttemptDirectConnectionWhenPreviouslyBonded;
+    private final boolean mAutomaticallyReconnectGattWhenNeeded;
+    private final boolean mSkipConnectingProfiles;
+    private final boolean mIgnoreUuidTimeoutAfterBonded;
+    private final boolean mSpecifyCreateBondTransportType;
+    private final int mCreateBondTransportType;
+    private final boolean mIncreaseIntentFilterPriority;
+    private final boolean mEvaluatePerformance;
+    private final Preferences.ExtraLoggingInformation mExtraLoggingInformation;
+    private final boolean mEnableNamingCharacteristic;
+    private final boolean mEnableFirmwareVersionCharacteristic;
+    private final boolean mKeepSameAccountKeyWrite;
+    private final boolean mIsRetroactivePairing;
+    private final int mNumSdpAttemptsAfterBonded;
+    private final boolean mSupportHidDevice;
+    private final boolean mEnablePairingWhileDirectlyConnecting;
+    private final boolean mAcceptConsentForFastPairOne;
+    private final int mGattConnectRetryTimeoutMillis;
+    private final boolean mEnable128BitCustomGattCharacteristicsId;
+    private final boolean mEnableSendExceptionStepToValidator;
+    private final boolean mEnableAdditionalDataTypeWhenActionOverBle;
+    private final boolean mCheckBondStateWhenSkipConnectingProfiles;
+    private final boolean mHandlePasskeyConfirmationByUi;
+    private final boolean mEnablePairFlowShowUiWithoutProfileConnection;
+
+    private Preferences(
+            int gattOperationTimeoutSeconds,
+            int gattConnectionTimeoutSeconds,
+            int bluetoothToggleTimeoutSeconds,
+            int bluetoothToggleSleepSeconds,
+            int classicDiscoveryTimeoutSeconds,
+            int numDiscoverAttempts,
+            int discoveryRetrySleepSeconds,
+            boolean ignoreDiscoveryError,
+            int sdpTimeoutSeconds,
+            int numSdpAttempts,
+            int numCreateBondAttempts,
+            int numConnectAttempts,
+            int numWriteAccountKeyAttempts,
+            boolean toggleBluetoothOnFailure,
+            boolean bluetoothStateUsesPolling,
+            int bluetoothStatePollingMillis,
+            int numAttempts,
+            boolean enableBrEdrHandover,
+            short brHandoverDataCharacteristicId,
+            short bluetoothSigDataCharacteristicId,
+            short firmwareVersionCharacteristicId,
+            short brTransportBlockDataDescriptorId,
+            boolean waitForUuidsAfterBonding,
+            boolean receiveUuidsAndBondedEventBeforeClose,
+            int removeBondTimeoutSeconds,
+            int removeBondSleepMillis,
+            int createBondTimeoutSeconds,
+            int hidCreateBondTimeoutSeconds,
+            int proxyTimeoutSeconds,
+            boolean rejectPhonebookAccess,
+            boolean rejectMessageAccess,
+            boolean rejectSimAccess,
+            int writeAccountKeySleepMillis,
+            boolean skipDisconnectingGattBeforeWritingAccountKey,
+            boolean moreEventLogForQuality,
+            boolean retryGattConnectionAndSecretHandshake,
+            long gattConnectShortTimeoutMs,
+            long gattConnectLongTimeoutMs,
+            long gattConnectShortTimeoutRetryMaxSpentTimeMs,
+            long addressRotateRetryMaxSpentTimeMs,
+            long pairingRetryDelayMs,
+            long secretHandshakeShortTimeoutMs,
+            long secretHandshakeLongTimeoutMs,
+            long secretHandshakeShortTimeoutRetryMaxSpentTimeMs,
+            long secretHandshakeLongTimeoutRetryMaxSpentTimeMs,
+            long secretHandshakeRetryAttempts,
+            long secretHandshakeRetryGattConnectionMaxSpentTimeMs,
+            long signalLostRetryMaxSpentTimeMs,
+            ImmutableSet<Integer> gattConnectionAndSecretHandshakeNoRetryGattError,
+            boolean retrySecretHandshakeTimeout,
+            boolean logUserManualRetry,
+            int pairFailureCounts,
+            String cachedDeviceAddress,
+            String possibleCachedDeviceAddress,
+            int sameModelIdPairedDeviceCount,
+            boolean isDeviceFinishCheckAddressFromCache,
+            boolean logPairWithCachedModelId,
+            boolean directConnectProfileIfModelIdInCache,
+            boolean acceptPasskey,
+            byte[] supportedProfileUuids,
+            boolean providerInitiatesBondingIfSupported,
+            boolean attemptDirectConnectionWhenPreviouslyBonded,
+            boolean automaticallyReconnectGattWhenNeeded,
+            boolean skipConnectingProfiles,
+            boolean ignoreUuidTimeoutAfterBonded,
+            boolean specifyCreateBondTransportType,
+            int createBondTransportType,
+            boolean increaseIntentFilterPriority,
+            boolean evaluatePerformance,
+            @Nullable Preferences.ExtraLoggingInformation extraLoggingInformation,
+            boolean enableNamingCharacteristic,
+            boolean enableFirmwareVersionCharacteristic,
+            boolean keepSameAccountKeyWrite,
+            boolean isRetroactivePairing,
+            int numSdpAttemptsAfterBonded,
+            boolean supportHidDevice,
+            boolean enablePairingWhileDirectlyConnecting,
+            boolean acceptConsentForFastPairOne,
+            int gattConnectRetryTimeoutMillis,
+            boolean enable128BitCustomGattCharacteristicsId,
+            boolean enableSendExceptionStepToValidator,
+            boolean enableAdditionalDataTypeWhenActionOverBle,
+            boolean checkBondStateWhenSkipConnectingProfiles,
+            boolean handlePasskeyConfirmationByUi,
+            boolean enablePairFlowShowUiWithoutProfileConnection) {
+        this.mGattOperationTimeoutSeconds = gattOperationTimeoutSeconds;
+        this.mGattConnectionTimeoutSeconds = gattConnectionTimeoutSeconds;
+        this.mBluetoothToggleTimeoutSeconds = bluetoothToggleTimeoutSeconds;
+        this.mBluetoothToggleSleepSeconds = bluetoothToggleSleepSeconds;
+        this.mClassicDiscoveryTimeoutSeconds = classicDiscoveryTimeoutSeconds;
+        this.mNumDiscoverAttempts = numDiscoverAttempts;
+        this.mDiscoveryRetrySleepSeconds = discoveryRetrySleepSeconds;
+        this.mIgnoreDiscoveryError = ignoreDiscoveryError;
+        this.mSdpTimeoutSeconds = sdpTimeoutSeconds;
+        this.mNumSdpAttempts = numSdpAttempts;
+        this.mNumCreateBondAttempts = numCreateBondAttempts;
+        this.mNumConnectAttempts = numConnectAttempts;
+        this.mNumWriteAccountKeyAttempts = numWriteAccountKeyAttempts;
+        this.mToggleBluetoothOnFailure = toggleBluetoothOnFailure;
+        this.mBluetoothStateUsesPolling = bluetoothStateUsesPolling;
+        this.mBluetoothStatePollingMillis = bluetoothStatePollingMillis;
+        this.mNumAttempts = numAttempts;
+        this.mEnableBrEdrHandover = enableBrEdrHandover;
+        this.mBrHandoverDataCharacteristicId = brHandoverDataCharacteristicId;
+        this.mBluetoothSigDataCharacteristicId = bluetoothSigDataCharacteristicId;
+        this.mFirmwareVersionCharacteristicId = firmwareVersionCharacteristicId;
+        this.mBrTransportBlockDataDescriptorId = brTransportBlockDataDescriptorId;
+        this.mWaitForUuidsAfterBonding = waitForUuidsAfterBonding;
+        this.mReceiveUuidsAndBondedEventBeforeClose = receiveUuidsAndBondedEventBeforeClose;
+        this.mRemoveBondTimeoutSeconds = removeBondTimeoutSeconds;
+        this.mRemoveBondSleepMillis = removeBondSleepMillis;
+        this.mCreateBondTimeoutSeconds = createBondTimeoutSeconds;
+        this.mHidCreateBondTimeoutSeconds = hidCreateBondTimeoutSeconds;
+        this.mProxyTimeoutSeconds = proxyTimeoutSeconds;
+        this.mRejectPhonebookAccess = rejectPhonebookAccess;
+        this.mRejectMessageAccess = rejectMessageAccess;
+        this.mRejectSimAccess = rejectSimAccess;
+        this.mWriteAccountKeySleepMillis = writeAccountKeySleepMillis;
+        this.mSkipDisconnectingGattBeforeWritingAccountKey =
+                skipDisconnectingGattBeforeWritingAccountKey;
+        this.mMoreEventLogForQuality = moreEventLogForQuality;
+        this.mRetryGattConnectionAndSecretHandshake = retryGattConnectionAndSecretHandshake;
+        this.mGattConnectShortTimeoutMs = gattConnectShortTimeoutMs;
+        this.mGattConnectLongTimeoutMs = gattConnectLongTimeoutMs;
+        this.mGattConnectShortTimeoutRetryMaxSpentTimeMs =
+                gattConnectShortTimeoutRetryMaxSpentTimeMs;
+        this.mAddressRotateRetryMaxSpentTimeMs = addressRotateRetryMaxSpentTimeMs;
+        this.mPairingRetryDelayMs = pairingRetryDelayMs;
+        this.mSecretHandshakeShortTimeoutMs = secretHandshakeShortTimeoutMs;
+        this.mSecretHandshakeLongTimeoutMs = secretHandshakeLongTimeoutMs;
+        this.mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs =
+                secretHandshakeShortTimeoutRetryMaxSpentTimeMs;
+        this.mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs =
+                secretHandshakeLongTimeoutRetryMaxSpentTimeMs;
+        this.mSecretHandshakeRetryAttempts = secretHandshakeRetryAttempts;
+        this.mSecretHandshakeRetryGattConnectionMaxSpentTimeMs =
+                secretHandshakeRetryGattConnectionMaxSpentTimeMs;
+        this.mSignalLostRetryMaxSpentTimeMs = signalLostRetryMaxSpentTimeMs;
+        this.mGattConnectionAndSecretHandshakeNoRetryGattError =
+                gattConnectionAndSecretHandshakeNoRetryGattError;
+        this.mRetrySecretHandshakeTimeout = retrySecretHandshakeTimeout;
+        this.mLogUserManualRetry = logUserManualRetry;
+        this.mPairFailureCounts = pairFailureCounts;
+        this.mCachedDeviceAddress = cachedDeviceAddress;
+        this.mPossibleCachedDeviceAddress = possibleCachedDeviceAddress;
+        this.mSameModelIdPairedDeviceCount = sameModelIdPairedDeviceCount;
+        this.mIsDeviceFinishCheckAddressFromCache = isDeviceFinishCheckAddressFromCache;
+        this.mLogPairWithCachedModelId = logPairWithCachedModelId;
+        this.mDirectConnectProfileIfModelIdInCache = directConnectProfileIfModelIdInCache;
+        this.mAcceptPasskey = acceptPasskey;
+        this.mSupportedProfileUuids = supportedProfileUuids;
+        this.mProviderInitiatesBondingIfSupported = providerInitiatesBondingIfSupported;
+        this.mAttemptDirectConnectionWhenPreviouslyBonded =
+                attemptDirectConnectionWhenPreviouslyBonded;
+        this.mAutomaticallyReconnectGattWhenNeeded = automaticallyReconnectGattWhenNeeded;
+        this.mSkipConnectingProfiles = skipConnectingProfiles;
+        this.mIgnoreUuidTimeoutAfterBonded = ignoreUuidTimeoutAfterBonded;
+        this.mSpecifyCreateBondTransportType = specifyCreateBondTransportType;
+        this.mCreateBondTransportType = createBondTransportType;
+        this.mIncreaseIntentFilterPriority = increaseIntentFilterPriority;
+        this.mEvaluatePerformance = evaluatePerformance;
+        this.mExtraLoggingInformation = extraLoggingInformation;
+        this.mEnableNamingCharacteristic = enableNamingCharacteristic;
+        this.mEnableFirmwareVersionCharacteristic = enableFirmwareVersionCharacteristic;
+        this.mKeepSameAccountKeyWrite = keepSameAccountKeyWrite;
+        this.mIsRetroactivePairing = isRetroactivePairing;
+        this.mNumSdpAttemptsAfterBonded = numSdpAttemptsAfterBonded;
+        this.mSupportHidDevice = supportHidDevice;
+        this.mEnablePairingWhileDirectlyConnecting = enablePairingWhileDirectlyConnecting;
+        this.mAcceptConsentForFastPairOne = acceptConsentForFastPairOne;
+        this.mGattConnectRetryTimeoutMillis = gattConnectRetryTimeoutMillis;
+        this.mEnable128BitCustomGattCharacteristicsId = enable128BitCustomGattCharacteristicsId;
+        this.mEnableSendExceptionStepToValidator = enableSendExceptionStepToValidator;
+        this.mEnableAdditionalDataTypeWhenActionOverBle = enableAdditionalDataTypeWhenActionOverBle;
+        this.mCheckBondStateWhenSkipConnectingProfiles = checkBondStateWhenSkipConnectingProfiles;
+        this.mHandlePasskeyConfirmationByUi = handlePasskeyConfirmationByUi;
+        this.mEnablePairFlowShowUiWithoutProfileConnection =
+                enablePairFlowShowUiWithoutProfileConnection;
+    }
+
+    /**
+     * Timeout for each GATT operation (not for the whole pairing process).
+     */
+    public int getGattOperationTimeoutSeconds() {
+        return mGattOperationTimeoutSeconds;
+    }
+
+    /**
+     * Timeout for Gatt connection operation.
+     */
+    public int getGattConnectionTimeoutSeconds() {
+        return mGattConnectionTimeoutSeconds;
+    }
+
+    /**
+     * Timeout for Bluetooth toggle.
+     */
+    public int getBluetoothToggleTimeoutSeconds() {
+        return mBluetoothToggleTimeoutSeconds;
+    }
+
+    /**
+     * Sleep time for Bluetooth toggle.
+     */
+    public int getBluetoothToggleSleepSeconds() {
+        return mBluetoothToggleSleepSeconds;
+    }
+
+    /**
+     * Timeout for classic discovery.
+     */
+    public int getClassicDiscoveryTimeoutSeconds() {
+        return mClassicDiscoveryTimeoutSeconds;
+    }
+
+    /**
+     * Number of discovery attempts allowed.
+     */
+    public int getNumDiscoverAttempts() {
+        return mNumDiscoverAttempts;
+    }
+
+    /**
+     * Sleep time between discovery retry.
+     */
+    public int getDiscoveryRetrySleepSeconds() {
+        return mDiscoveryRetrySleepSeconds;
+    }
+
+    /**
+     * Whether to ignore error incurred during discovery.
+     */
+    public boolean getIgnoreDiscoveryError() {
+        return mIgnoreDiscoveryError;
+    }
+
+    /**
+     * Timeout for Sdp.
+     */
+    public int getSdpTimeoutSeconds() {
+        return mSdpTimeoutSeconds;
+    }
+
+    /**
+     * Number of Sdp attempts allowed.
+     */
+    public int getNumSdpAttempts() {
+        return mNumSdpAttempts;
+    }
+
+    /**
+     * Number of create bond attempts allowed.
+     */
+    public int getNumCreateBondAttempts() {
+        return mNumCreateBondAttempts;
+    }
+
+    /**
+     * Number of connect attempts allowed.
+     */
+    public int getNumConnectAttempts() {
+        return mNumConnectAttempts;
+    }
+
+    /**
+     * Number of write account key attempts allowed.
+     */
+    public int getNumWriteAccountKeyAttempts() {
+        return mNumWriteAccountKeyAttempts;
+    }
+
+    /**
+     * Returns whether it is OK toggle bluetooth to retry upon failure.
+     */
+    public boolean getToggleBluetoothOnFailure() {
+        return mToggleBluetoothOnFailure;
+    }
+
+    /**
+     * Whether to get Bluetooth state using polling.
+     */
+    public boolean getBluetoothStateUsesPolling() {
+        return mBluetoothStateUsesPolling;
+    }
+
+    /**
+     * Polling time when retrieving Bluetooth state.
+     */
+    public int getBluetoothStatePollingMillis() {
+        return mBluetoothStatePollingMillis;
+    }
+
+    /**
+     * The number of times to attempt a generic operation, before giving up.
+     */
+    public int getNumAttempts() {
+        return mNumAttempts;
+    }
+
+    /**
+     * Returns whether BrEdr handover is enabled.
+     */
+    public boolean getEnableBrEdrHandover() {
+        return mEnableBrEdrHandover;
+    }
+
+    /**
+     * Returns characteristic Id for Br Handover data.
+     */
+    public short getBrHandoverDataCharacteristicId() {
+        return mBrHandoverDataCharacteristicId;
+    }
+
+    /**
+     * Returns characteristic Id for Bluethoth Sig data.
+     */
+    public short getBluetoothSigDataCharacteristicId() {
+        return mBluetoothSigDataCharacteristicId;
+    }
+
+    /**
+     * Returns characteristic Id for Firmware version.
+     */
+    public short getFirmwareVersionCharacteristicId() {
+        return mFirmwareVersionCharacteristicId;
+    }
+
+    /**
+     * Returns descripter Id for Br transport block data.
+     */
+    public short getBrTransportBlockDataDescriptorId() {
+        return mBrTransportBlockDataDescriptorId;
+    }
+
+    /**
+     * Whether to wait for Uuids after bonding.
+     */
+    public boolean getWaitForUuidsAfterBonding() {
+        return mWaitForUuidsAfterBonding;
+    }
+
+    /**
+     * Whether to get received Uuids and bonded events before close.
+     */
+    public boolean getReceiveUuidsAndBondedEventBeforeClose() {
+        return mReceiveUuidsAndBondedEventBeforeClose;
+    }
+
+    /**
+     * Timeout for remove bond operation.
+     */
+    public int getRemoveBondTimeoutSeconds() {
+        return mRemoveBondTimeoutSeconds;
+    }
+
+    /**
+     * Sleep time for remove bond operation.
+     */
+    public int getRemoveBondSleepMillis() {
+        return mRemoveBondSleepMillis;
+    }
+
+    /**
+     * This almost always succeeds (or fails) in 2-10 seconds (Taimen running O -> Nexus 6P sim).
+     */
+    public int getCreateBondTimeoutSeconds() {
+        return mCreateBondTimeoutSeconds;
+    }
+
+    /**
+     * Timeout for creating bond with Hid devices.
+     */
+    public int getHidCreateBondTimeoutSeconds() {
+        return mHidCreateBondTimeoutSeconds;
+    }
+
+    /**
+     * Timeout for get proxy operation.
+     */
+    public int getProxyTimeoutSeconds() {
+        return mProxyTimeoutSeconds;
+    }
+
+    /**
+     * Whether to reject phone book access.
+     */
+    public boolean getRejectPhonebookAccess() {
+        return mRejectPhonebookAccess;
+    }
+
+    /**
+     * Whether to reject message access.
+     */
+    public boolean getRejectMessageAccess() {
+        return mRejectMessageAccess;
+    }
+
+    /**
+     * Whether to reject sim access.
+     */
+    public boolean getRejectSimAccess() {
+        return mRejectSimAccess;
+    }
+
+    /**
+     * Sleep time for write account key operation.
+     */
+    public int getWriteAccountKeySleepMillis() {
+        return mWriteAccountKeySleepMillis;
+    }
+
+    /**
+     * Whether to skip disconneting gatt before writing account key.
+     */
+    public boolean getSkipDisconnectingGattBeforeWritingAccountKey() {
+        return mSkipDisconnectingGattBeforeWritingAccountKey;
+    }
+
+    /**
+     * Whether to get more event log for quality improvement.
+     */
+    public boolean getMoreEventLogForQuality() {
+        return mMoreEventLogForQuality;
+    }
+
+    /**
+     * Whether to retry gatt connection and secrete handshake.
+     */
+    public boolean getRetryGattConnectionAndSecretHandshake() {
+        return mRetryGattConnectionAndSecretHandshake;
+    }
+
+    /**
+     * Short Gatt connection timeoout.
+     */
+    public long getGattConnectShortTimeoutMs() {
+        return mGattConnectShortTimeoutMs;
+    }
+
+    /**
+     * Long Gatt connection timeout.
+     */
+    public long getGattConnectLongTimeoutMs() {
+        return mGattConnectLongTimeoutMs;
+    }
+
+    /**
+     * Short Timeout for Gatt connection, including retry.
+     */
+    public long getGattConnectShortTimeoutRetryMaxSpentTimeMs() {
+        return mGattConnectShortTimeoutRetryMaxSpentTimeMs;
+    }
+
+    /**
+     * Timeout for address rotation, including retry.
+     */
+    public long getAddressRotateRetryMaxSpentTimeMs() {
+        return mAddressRotateRetryMaxSpentTimeMs;
+    }
+
+    /**
+     * Returns pairing retry delay time.
+     */
+    public long getPairingRetryDelayMs() {
+        return mPairingRetryDelayMs;
+    }
+
+    /**
+     * Short timeout for secrete handshake.
+     */
+    public long getSecretHandshakeShortTimeoutMs() {
+        return mSecretHandshakeShortTimeoutMs;
+    }
+
+    /**
+     * Long timeout for secret handshake.
+     */
+    public long getSecretHandshakeLongTimeoutMs() {
+        return mSecretHandshakeLongTimeoutMs;
+    }
+
+    /**
+     * Short timeout for secret handshake, including retry.
+     */
+    public long getSecretHandshakeShortTimeoutRetryMaxSpentTimeMs() {
+        return mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs;
+    }
+
+    /**
+     * Long timeout for secret handshake, including retry.
+     */
+    public long getSecretHandshakeLongTimeoutRetryMaxSpentTimeMs() {
+        return mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs;
+    }
+
+    /**
+     * Number of secrete handshake retry allowed.
+     */
+    public long getSecretHandshakeRetryAttempts() {
+        return mSecretHandshakeRetryAttempts;
+    }
+
+    /**
+     * Timeout for secrete handshake and gatt connection, including retry.
+     */
+    public long getSecretHandshakeRetryGattConnectionMaxSpentTimeMs() {
+        return mSecretHandshakeRetryGattConnectionMaxSpentTimeMs;
+    }
+
+    /**
+     * Timeout for signal lost handling, including retry.
+     */
+    public long getSignalLostRetryMaxSpentTimeMs() {
+        return mSignalLostRetryMaxSpentTimeMs;
+    }
+
+    /**
+     * Returns error for gatt connection and secrete handshake, without retry.
+     */
+    public ImmutableSet<Integer> getGattConnectionAndSecretHandshakeNoRetryGattError() {
+        return mGattConnectionAndSecretHandshakeNoRetryGattError;
+    }
+
+    /**
+     * Whether to retry upon secrete handshake timeout.
+     */
+    public boolean getRetrySecretHandshakeTimeout() {
+        return mRetrySecretHandshakeTimeout;
+    }
+
+    /**
+     * Wehther to log user manual retry.
+     */
+    public boolean getLogUserManualRetry() {
+        return mLogUserManualRetry;
+    }
+
+    /**
+     * Returns number of pairing failure counts.
+     */
+    public int getPairFailureCounts() {
+        return mPairFailureCounts;
+    }
+
+    /**
+     * Returns cached device address.
+     */
+    public String getCachedDeviceAddress() {
+        return mCachedDeviceAddress;
+    }
+
+    /**
+     * Returns possible cached device address.
+     */
+    public String getPossibleCachedDeviceAddress() {
+        return mPossibleCachedDeviceAddress;
+    }
+
+    /**
+     * Returns count of paired devices from the same model Id.
+     */
+    public int getSameModelIdPairedDeviceCount() {
+        return mSameModelIdPairedDeviceCount;
+    }
+
+    /**
+     * Whether the bonded device address is in the Cache .
+     */
+    public boolean getIsDeviceFinishCheckAddressFromCache() {
+        return mIsDeviceFinishCheckAddressFromCache;
+    }
+
+    /**
+     * Whether to log pairing info when cached model Id is hit.
+     */
+    public boolean getLogPairWithCachedModelId() {
+        return mLogPairWithCachedModelId;
+    }
+
+    /**
+     * Whether to directly connnect to a profile of a device, whose model Id is in cache.
+     */
+    public boolean getDirectConnectProfileIfModelIdInCache() {
+        return mDirectConnectProfileIfModelIdInCache;
+    }
+
+    /**
+     * Whether to auto-accept
+     * {@link android.bluetooth.BluetoothDevice#PAIRING_VARIANT_PASSKEY_CONFIRMATION}.
+     * Only the Fast Pair Simulator (which runs on an Android device) sends this. Since real
+     * Bluetooth headphones don't have displays, they use secure simple pairing (no pin code
+     * confirmation; we get no pairing request broadcast at all). So we may want to turn this off in
+     * prod.
+     */
+    public boolean getAcceptPasskey() {
+        return mAcceptPasskey;
+    }
+
+    /**
+     * Returns Uuids for supported profiles.
+     */
+    @SuppressWarnings("mutable")
+    public byte[] getSupportedProfileUuids() {
+        return mSupportedProfileUuids;
+    }
+
+    /**
+     * If true, after the Key-based Pairing BLE handshake, we wait for the headphones to send a
+     * pairing request to us; if false, we send the request to them.
+     */
+    public boolean getProviderInitiatesBondingIfSupported() {
+        return mProviderInitiatesBondingIfSupported;
+    }
+
+    /**
+     * If true, the first step will be attempting to connect directly to our supported profiles when
+     * a device has previously been bonded. This will help with performance on subsequent bondings
+     * and help to increase reliability in some cases.
+     */
+    public boolean getAttemptDirectConnectionWhenPreviouslyBonded() {
+        return mAttemptDirectConnectionWhenPreviouslyBonded;
+    }
+
+    /**
+     * If true, closed Gatt connections will be reopened when they are needed again. Otherwise, they
+     * will remain closed until they are explicitly reopened.
+     */
+    public boolean getAutomaticallyReconnectGattWhenNeeded() {
+        return mAutomaticallyReconnectGattWhenNeeded;
+    }
+
+    /**
+     * If true, we'll finish the pairing process after we've created a bond instead of after
+     * connecting a profile.
+     */
+    public boolean getSkipConnectingProfiles() {
+        return mSkipConnectingProfiles;
+    }
+
+    /**
+     * If true, continues the pairing process if we've timed out due to not receiving UUIDs from the
+     * headset. We can still attempt to connect to A2DP afterwards. If false, Fast Pair will fail
+     * after this step since we're expecting to receive the UUIDs.
+     */
+    public boolean getIgnoreUuidTimeoutAfterBonded() {
+        return mIgnoreUuidTimeoutAfterBonded;
+    }
+
+    /**
+     * If true, a specific transport type will be included in the create bond request, which will be
+     * used for dual mode devices. Otherwise, we'll use the platform defined default which is
+     * BluetoothDevice.TRANSPORT_AUTO. See {@link #getCreateBondTransportType()}.
+     */
+    public boolean getSpecifyCreateBondTransportType() {
+        return mSpecifyCreateBondTransportType;
+    }
+
+    /**
+     * The transport type to use when creating a bond when
+     * {@link #getSpecifyCreateBondTransportType() is true. This should be one of
+     * BluetoothDevice.TRANSPORT_AUTO, BluetoothDevice.TRANSPORT_BREDR,
+     * or BluetoothDevice.TRANSPORT_LE.
+     */
+    public int getCreateBondTransportType() {
+        return mCreateBondTransportType;
+    }
+
+    /**
+     * Whether to increase intent filter priority.
+     */
+    public boolean getIncreaseIntentFilterPriority() {
+        return mIncreaseIntentFilterPriority;
+    }
+
+    /**
+     * Whether to evaluate performance.
+     */
+    public boolean getEvaluatePerformance() {
+        return mEvaluatePerformance;
+    }
+
+    /**
+     * Returns extra logging information.
+     */
+    @Nullable
+    public ExtraLoggingInformation getExtraLoggingInformation() {
+        return mExtraLoggingInformation;
+    }
+
+    /**
+     * Whether to enable naming characteristic.
+     */
+    public boolean getEnableNamingCharacteristic() {
+        return mEnableNamingCharacteristic;
+    }
+
+    /**
+     * Whether to enable firmware version characteristic.
+     */
+    public boolean getEnableFirmwareVersionCharacteristic() {
+        return mEnableFirmwareVersionCharacteristic;
+    }
+
+    /**
+     * If true, even Fast Pair identifies a provider have paired with the account, still writes the
+     * identified account key to the provider.
+     */
+    public boolean getKeepSameAccountKeyWrite() {
+        return mKeepSameAccountKeyWrite;
+    }
+
+    /**
+     * If true, run retroactive pairing.
+     */
+    public boolean getIsRetroactivePairing() {
+        return mIsRetroactivePairing;
+    }
+
+    /**
+     * If it's larger than 0, {@link android.bluetooth.BluetoothDevice#fetchUuidsWithSdp} would be
+     * triggered with number of attempts after device is bonded and no profiles were automatically
+     * discovered".
+     */
+    public int getNumSdpAttemptsAfterBonded() {
+        return mNumSdpAttemptsAfterBonded;
+    }
+
+    /**
+     * If true, supports HID device for fastpair.
+     */
+    public boolean getSupportHidDevice() {
+        return mSupportHidDevice;
+    }
+
+    /**
+     * If true, we'll enable the pairing behavior to handle the state transition from BOND_BONDED to
+     * BOND_BONDING when directly connecting profiles.
+     */
+    public boolean getEnablePairingWhileDirectlyConnecting() {
+        return mEnablePairingWhileDirectlyConnecting;
+    }
+
+    /**
+     * If true, we will accept the user confirmation when bonding with FastPair 1.0 devices.
+     */
+    public boolean getAcceptConsentForFastPairOne() {
+        return mAcceptConsentForFastPairOne;
+    }
+
+    /**
+     * If it's larger than 0, we will retry connecting GATT within the timeout.
+     */
+    public int getGattConnectRetryTimeoutMillis() {
+        return mGattConnectRetryTimeoutMillis;
+    }
+
+    /**
+     * If true, then uses the new custom GATT characteristics {go/fastpair-128bit-gatt}.
+     */
+    public boolean getEnable128BitCustomGattCharacteristicsId() {
+        return mEnable128BitCustomGattCharacteristicsId;
+    }
+
+    /**
+     * If true, then sends the internal pair step or Exception to Validator by Intent.
+     */
+    public boolean getEnableSendExceptionStepToValidator() {
+        return mEnableSendExceptionStepToValidator;
+    }
+
+    /**
+     * If true, then adds the additional data type in the handshake packet when action over BLE.
+     */
+    public boolean getEnableAdditionalDataTypeWhenActionOverBle() {
+        return mEnableAdditionalDataTypeWhenActionOverBle;
+    }
+
+    /**
+     * If true, then checks the bond state when skips connecting profiles in the pairing shortcut.
+     */
+    public boolean getCheckBondStateWhenSkipConnectingProfiles() {
+        return mCheckBondStateWhenSkipConnectingProfiles;
+    }
+
+    /**
+     * If true, the passkey confirmation will be handled by the half-sheet UI.
+     */
+    public boolean getHandlePasskeyConfirmationByUi() {
+        return mHandlePasskeyConfirmationByUi;
+    }
+
+    /**
+     * If true, then use pair flow to show ui when pairing is finished without connecting profile.
+     */
+    public boolean getEnablePairFlowShowUiWithoutProfileConnection() {
+        return mEnablePairFlowShowUiWithoutProfileConnection;
+    }
+
+    @Override
+    public String toString() {
+        return "Preferences{"
+                + "gattOperationTimeoutSeconds=" + mGattOperationTimeoutSeconds + ", "
+                + "gattConnectionTimeoutSeconds=" + mGattConnectionTimeoutSeconds + ", "
+                + "bluetoothToggleTimeoutSeconds=" + mBluetoothToggleTimeoutSeconds + ", "
+                + "bluetoothToggleSleepSeconds=" + mBluetoothToggleSleepSeconds + ", "
+                + "classicDiscoveryTimeoutSeconds=" + mClassicDiscoveryTimeoutSeconds + ", "
+                + "numDiscoverAttempts=" + mNumDiscoverAttempts + ", "
+                + "discoveryRetrySleepSeconds=" + mDiscoveryRetrySleepSeconds + ", "
+                + "ignoreDiscoveryError=" + mIgnoreDiscoveryError + ", "
+                + "sdpTimeoutSeconds=" + mSdpTimeoutSeconds + ", "
+                + "numSdpAttempts=" + mNumSdpAttempts + ", "
+                + "numCreateBondAttempts=" + mNumCreateBondAttempts + ", "
+                + "numConnectAttempts=" + mNumConnectAttempts + ", "
+                + "numWriteAccountKeyAttempts=" + mNumWriteAccountKeyAttempts + ", "
+                + "toggleBluetoothOnFailure=" + mToggleBluetoothOnFailure + ", "
+                + "bluetoothStateUsesPolling=" + mBluetoothStateUsesPolling + ", "
+                + "bluetoothStatePollingMillis=" + mBluetoothStatePollingMillis + ", "
+                + "numAttempts=" + mNumAttempts + ", "
+                + "enableBrEdrHandover=" + mEnableBrEdrHandover + ", "
+                + "brHandoverDataCharacteristicId=" + mBrHandoverDataCharacteristicId + ", "
+                + "bluetoothSigDataCharacteristicId=" + mBluetoothSigDataCharacteristicId + ", "
+                + "firmwareVersionCharacteristicId=" + mFirmwareVersionCharacteristicId + ", "
+                + "brTransportBlockDataDescriptorId=" + mBrTransportBlockDataDescriptorId + ", "
+                + "waitForUuidsAfterBonding=" + mWaitForUuidsAfterBonding + ", "
+                + "receiveUuidsAndBondedEventBeforeClose=" + mReceiveUuidsAndBondedEventBeforeClose
+                + ", "
+                + "removeBondTimeoutSeconds=" + mRemoveBondTimeoutSeconds + ", "
+                + "removeBondSleepMillis=" + mRemoveBondSleepMillis + ", "
+                + "createBondTimeoutSeconds=" + mCreateBondTimeoutSeconds + ", "
+                + "hidCreateBondTimeoutSeconds=" + mHidCreateBondTimeoutSeconds + ", "
+                + "proxyTimeoutSeconds=" + mProxyTimeoutSeconds + ", "
+                + "rejectPhonebookAccess=" + mRejectPhonebookAccess + ", "
+                + "rejectMessageAccess=" + mRejectMessageAccess + ", "
+                + "rejectSimAccess=" + mRejectSimAccess + ", "
+                + "writeAccountKeySleepMillis=" + mWriteAccountKeySleepMillis + ", "
+                + "skipDisconnectingGattBeforeWritingAccountKey="
+                + mSkipDisconnectingGattBeforeWritingAccountKey + ", "
+                + "moreEventLogForQuality=" + mMoreEventLogForQuality + ", "
+                + "retryGattConnectionAndSecretHandshake=" + mRetryGattConnectionAndSecretHandshake
+                + ", "
+                + "gattConnectShortTimeoutMs=" + mGattConnectShortTimeoutMs + ", "
+                + "gattConnectLongTimeoutMs=" + mGattConnectLongTimeoutMs + ", "
+                + "gattConnectShortTimeoutRetryMaxSpentTimeMs="
+                + mGattConnectShortTimeoutRetryMaxSpentTimeMs + ", "
+                + "addressRotateRetryMaxSpentTimeMs=" + mAddressRotateRetryMaxSpentTimeMs + ", "
+                + "pairingRetryDelayMs=" + mPairingRetryDelayMs + ", "
+                + "secretHandshakeShortTimeoutMs=" + mSecretHandshakeShortTimeoutMs + ", "
+                + "secretHandshakeLongTimeoutMs=" + mSecretHandshakeLongTimeoutMs + ", "
+                + "secretHandshakeShortTimeoutRetryMaxSpentTimeMs="
+                + mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs + ", "
+                + "secretHandshakeLongTimeoutRetryMaxSpentTimeMs="
+                + mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs + ", "
+                + "secretHandshakeRetryAttempts=" + mSecretHandshakeRetryAttempts + ", "
+                + "secretHandshakeRetryGattConnectionMaxSpentTimeMs="
+                + mSecretHandshakeRetryGattConnectionMaxSpentTimeMs + ", "
+                + "signalLostRetryMaxSpentTimeMs=" + mSignalLostRetryMaxSpentTimeMs + ", "
+                + "gattConnectionAndSecretHandshakeNoRetryGattError="
+                + mGattConnectionAndSecretHandshakeNoRetryGattError + ", "
+                + "retrySecretHandshakeTimeout=" + mRetrySecretHandshakeTimeout + ", "
+                + "logUserManualRetry=" + mLogUserManualRetry + ", "
+                + "pairFailureCounts=" + mPairFailureCounts + ", "
+                + "cachedDeviceAddress=" + mCachedDeviceAddress + ", "
+                + "possibleCachedDeviceAddress=" + mPossibleCachedDeviceAddress + ", "
+                + "sameModelIdPairedDeviceCount=" + mSameModelIdPairedDeviceCount + ", "
+                + "isDeviceFinishCheckAddressFromCache=" + mIsDeviceFinishCheckAddressFromCache
+                + ", "
+                + "logPairWithCachedModelId=" + mLogPairWithCachedModelId + ", "
+                + "directConnectProfileIfModelIdInCache=" + mDirectConnectProfileIfModelIdInCache
+                + ", "
+                + "acceptPasskey=" + mAcceptPasskey + ", "
+                + "supportedProfileUuids=" + Arrays.toString(mSupportedProfileUuids) + ", "
+                + "providerInitiatesBondingIfSupported=" + mProviderInitiatesBondingIfSupported
+                + ", "
+                + "attemptDirectConnectionWhenPreviouslyBonded="
+                + mAttemptDirectConnectionWhenPreviouslyBonded + ", "
+                + "automaticallyReconnectGattWhenNeeded=" + mAutomaticallyReconnectGattWhenNeeded
+                + ", "
+                + "skipConnectingProfiles=" + mSkipConnectingProfiles + ", "
+                + "ignoreUuidTimeoutAfterBonded=" + mIgnoreUuidTimeoutAfterBonded + ", "
+                + "specifyCreateBondTransportType=" + mSpecifyCreateBondTransportType + ", "
+                + "createBondTransportType=" + mCreateBondTransportType + ", "
+                + "increaseIntentFilterPriority=" + mIncreaseIntentFilterPriority + ", "
+                + "evaluatePerformance=" + mEvaluatePerformance + ", "
+                + "extraLoggingInformation=" + mExtraLoggingInformation + ", "
+                + "enableNamingCharacteristic=" + mEnableNamingCharacteristic + ", "
+                + "enableFirmwareVersionCharacteristic=" + mEnableFirmwareVersionCharacteristic
+                + ", "
+                + "keepSameAccountKeyWrite=" + mKeepSameAccountKeyWrite + ", "
+                + "isRetroactivePairing=" + mIsRetroactivePairing + ", "
+                + "numSdpAttemptsAfterBonded=" + mNumSdpAttemptsAfterBonded + ", "
+                + "supportHidDevice=" + mSupportHidDevice + ", "
+                + "enablePairingWhileDirectlyConnecting=" + mEnablePairingWhileDirectlyConnecting
+                + ", "
+                + "acceptConsentForFastPairOne=" + mAcceptConsentForFastPairOne + ", "
+                + "gattConnectRetryTimeoutMillis=" + mGattConnectRetryTimeoutMillis + ", "
+                + "enable128BitCustomGattCharacteristicsId="
+                + mEnable128BitCustomGattCharacteristicsId + ", "
+                + "enableSendExceptionStepToValidator=" + mEnableSendExceptionStepToValidator + ", "
+                + "enableAdditionalDataTypeWhenActionOverBle="
+                + mEnableAdditionalDataTypeWhenActionOverBle + ", "
+                + "checkBondStateWhenSkipConnectingProfiles="
+                + mCheckBondStateWhenSkipConnectingProfiles + ", "
+                + "handlePasskeyConfirmationByUi=" + mHandlePasskeyConfirmationByUi + ", "
+                + "enablePairFlowShowUiWithoutProfileConnection="
+                + mEnablePairFlowShowUiWithoutProfileConnection
+                + "}";
+    }
+
+    /**
+     * Converts an instance to a builder.
+     */
+    public Builder toBuilder() {
+        return new Preferences.Builder(this);
+    }
+
+    /**
+     * Constructs a builder.
+     */
+    public static Builder builder() {
+        return new Preferences.Builder()
+                .setGattOperationTimeoutSeconds(3)
+                .setGattConnectionTimeoutSeconds(15)
+                .setBluetoothToggleTimeoutSeconds(10)
+                .setBluetoothToggleSleepSeconds(2)
+                .setClassicDiscoveryTimeoutSeconds(10)
+                .setNumDiscoverAttempts(3)
+                .setDiscoveryRetrySleepSeconds(1)
+                .setIgnoreDiscoveryError(false)
+                .setSdpTimeoutSeconds(10)
+                .setNumSdpAttempts(3)
+                .setNumCreateBondAttempts(3)
+                .setNumConnectAttempts(1)
+                .setNumWriteAccountKeyAttempts(3)
+                .setToggleBluetoothOnFailure(false)
+                .setBluetoothStateUsesPolling(true)
+                .setBluetoothStatePollingMillis(1000)
+                .setNumAttempts(2)
+                .setEnableBrEdrHandover(false)
+                .setBrHandoverDataCharacteristicId(get16BitUuid(
+                        Constants.TransportDiscoveryService.BrHandoverDataCharacteristic.ID))
+                .setBluetoothSigDataCharacteristicId(get16BitUuid(
+                        Constants.TransportDiscoveryService.BluetoothSigDataCharacteristic.ID))
+                .setFirmwareVersionCharacteristicId(get16BitUuid(FirmwareVersionCharacteristic.ID))
+                .setBrTransportBlockDataDescriptorId(
+                        get16BitUuid(
+                                Constants.TransportDiscoveryService.BluetoothSigDataCharacteristic
+                                        .BrTransportBlockDataDescriptor.ID))
+                .setWaitForUuidsAfterBonding(true)
+                .setReceiveUuidsAndBondedEventBeforeClose(true)
+                .setRemoveBondTimeoutSeconds(5)
+                .setRemoveBondSleepMillis(1000)
+                .setCreateBondTimeoutSeconds(15)
+                .setHidCreateBondTimeoutSeconds(40)
+                .setProxyTimeoutSeconds(2)
+                .setRejectPhonebookAccess(false)
+                .setRejectMessageAccess(false)
+                .setRejectSimAccess(false)
+                .setAcceptPasskey(true)
+                .setSupportedProfileUuids(Constants.getSupportedProfiles())
+                .setWriteAccountKeySleepMillis(2000)
+                .setProviderInitiatesBondingIfSupported(false)
+                .setAttemptDirectConnectionWhenPreviouslyBonded(false)
+                .setAutomaticallyReconnectGattWhenNeeded(false)
+                .setSkipDisconnectingGattBeforeWritingAccountKey(false)
+                .setSkipConnectingProfiles(false)
+                .setIgnoreUuidTimeoutAfterBonded(false)
+                .setSpecifyCreateBondTransportType(false)
+                .setCreateBondTransportType(0 /*BluetoothDevice.TRANSPORT_AUTO*/)
+                .setIncreaseIntentFilterPriority(true)
+                .setEvaluatePerformance(false)
+                .setKeepSameAccountKeyWrite(true)
+                .setEnableNamingCharacteristic(false)
+                .setEnableFirmwareVersionCharacteristic(false)
+                .setIsRetroactivePairing(false)
+                .setNumSdpAttemptsAfterBonded(1)
+                .setSupportHidDevice(false)
+                .setEnablePairingWhileDirectlyConnecting(true)
+                .setAcceptConsentForFastPairOne(true)
+                .setGattConnectRetryTimeoutMillis(0)
+                .setEnable128BitCustomGattCharacteristicsId(true)
+                .setEnableSendExceptionStepToValidator(true)
+                .setEnableAdditionalDataTypeWhenActionOverBle(true)
+                .setCheckBondStateWhenSkipConnectingProfiles(true)
+                .setHandlePasskeyConfirmationByUi(false)
+                .setMoreEventLogForQuality(true)
+                .setRetryGattConnectionAndSecretHandshake(true)
+                .setGattConnectShortTimeoutMs(7000)
+                .setGattConnectLongTimeoutMs(15000)
+                .setGattConnectShortTimeoutRetryMaxSpentTimeMs(10000)
+                .setAddressRotateRetryMaxSpentTimeMs(15000)
+                .setPairingRetryDelayMs(100)
+                .setSecretHandshakeShortTimeoutMs(3000)
+                .setSecretHandshakeLongTimeoutMs(10000)
+                .setSecretHandshakeShortTimeoutRetryMaxSpentTimeMs(5000)
+                .setSecretHandshakeLongTimeoutRetryMaxSpentTimeMs(7000)
+                .setSecretHandshakeRetryAttempts(3)
+                .setSecretHandshakeRetryGattConnectionMaxSpentTimeMs(15000)
+                .setSignalLostRetryMaxSpentTimeMs(15000)
+                .setGattConnectionAndSecretHandshakeNoRetryGattError(ImmutableSet.of())
+                .setRetrySecretHandshakeTimeout(false)
+                .setLogUserManualRetry(true)
+                .setPairFailureCounts(0)
+                .setEnablePairFlowShowUiWithoutProfileConnection(true)
+                .setPairFailureCounts(0)
+                .setLogPairWithCachedModelId(true)
+                .setDirectConnectProfileIfModelIdInCache(false)
+                .setCachedDeviceAddress("")
+                .setPossibleCachedDeviceAddress("")
+                .setSameModelIdPairedDeviceCount(0)
+                .setIsDeviceFinishCheckAddressFromCache(true);
+    }
+
+    /**
+     * Constructs a builder from GmsLog.
+     */
+    // TODO(b/206668142): remove this builder once api is ready.
+    public static Builder builderFromGmsLog() {
+        return new Preferences.Builder()
+                .setGattOperationTimeoutSeconds(10)
+                .setGattConnectionTimeoutSeconds(15)
+                .setBluetoothToggleTimeoutSeconds(10)
+                .setBluetoothToggleSleepSeconds(2)
+                .setClassicDiscoveryTimeoutSeconds(13)
+                .setNumDiscoverAttempts(3)
+                .setDiscoveryRetrySleepSeconds(1)
+                .setIgnoreDiscoveryError(true)
+                .setSdpTimeoutSeconds(10)
+                .setNumSdpAttempts(0)
+                .setNumCreateBondAttempts(3)
+                .setNumConnectAttempts(2)
+                .setNumWriteAccountKeyAttempts(3)
+                .setToggleBluetoothOnFailure(false)
+                .setBluetoothStateUsesPolling(true)
+                .setBluetoothStatePollingMillis(1000)
+                .setNumAttempts(2)
+                .setEnableBrEdrHandover(false)
+                .setBrHandoverDataCharacteristicId((short) 11265)
+                .setBluetoothSigDataCharacteristicId((short) 11266)
+                .setFirmwareVersionCharacteristicId((short) 10790)
+                .setBrTransportBlockDataDescriptorId((short) 11267)
+                .setWaitForUuidsAfterBonding(true)
+                .setReceiveUuidsAndBondedEventBeforeClose(true)
+                .setRemoveBondTimeoutSeconds(5)
+                .setRemoveBondSleepMillis(1000)
+                .setCreateBondTimeoutSeconds(15)
+                .setHidCreateBondTimeoutSeconds(40)
+                .setProxyTimeoutSeconds(2)
+                .setRejectPhonebookAccess(false)
+                .setRejectMessageAccess(false)
+                .setRejectSimAccess(false)
+                .setAcceptPasskey(true)
+                .setSupportedProfileUuids(Constants.getSupportedProfiles())
+                .setWriteAccountKeySleepMillis(2000)
+                .setProviderInitiatesBondingIfSupported(false)
+                .setAttemptDirectConnectionWhenPreviouslyBonded(true)
+                .setAutomaticallyReconnectGattWhenNeeded(true)
+                .setSkipDisconnectingGattBeforeWritingAccountKey(true)
+                .setSkipConnectingProfiles(false)
+                .setIgnoreUuidTimeoutAfterBonded(true)
+                .setSpecifyCreateBondTransportType(false)
+                .setCreateBondTransportType(0 /*BluetoothDevice.TRANSPORT_AUTO*/)
+                .setIncreaseIntentFilterPriority(true)
+                .setEvaluatePerformance(true)
+                .setKeepSameAccountKeyWrite(true)
+                .setEnableNamingCharacteristic(true)
+                .setEnableFirmwareVersionCharacteristic(true)
+                .setIsRetroactivePairing(false)
+                .setNumSdpAttemptsAfterBonded(1)
+                .setSupportHidDevice(false)
+                .setEnablePairingWhileDirectlyConnecting(true)
+                .setAcceptConsentForFastPairOne(true)
+                .setGattConnectRetryTimeoutMillis(18000)
+                .setEnable128BitCustomGattCharacteristicsId(true)
+                .setEnableSendExceptionStepToValidator(true)
+                .setEnableAdditionalDataTypeWhenActionOverBle(true)
+                .setCheckBondStateWhenSkipConnectingProfiles(true)
+                .setHandlePasskeyConfirmationByUi(false)
+                .setMoreEventLogForQuality(true)
+                .setRetryGattConnectionAndSecretHandshake(true)
+                .setGattConnectShortTimeoutMs(7000)
+                .setGattConnectLongTimeoutMs(15000)
+                .setGattConnectShortTimeoutRetryMaxSpentTimeMs(10000)
+                .setAddressRotateRetryMaxSpentTimeMs(15000)
+                .setPairingRetryDelayMs(100)
+                .setSecretHandshakeShortTimeoutMs(3000)
+                .setSecretHandshakeLongTimeoutMs(10000)
+                .setSecretHandshakeShortTimeoutRetryMaxSpentTimeMs(5000)
+                .setSecretHandshakeLongTimeoutRetryMaxSpentTimeMs(7000)
+                .setSecretHandshakeRetryAttempts(3)
+                .setSecretHandshakeRetryGattConnectionMaxSpentTimeMs(15000)
+                .setSignalLostRetryMaxSpentTimeMs(15000)
+                .setGattConnectionAndSecretHandshakeNoRetryGattError(ImmutableSet.of(257))
+                .setRetrySecretHandshakeTimeout(false)
+                .setLogUserManualRetry(true)
+                .setPairFailureCounts(0)
+                .setEnablePairFlowShowUiWithoutProfileConnection(true)
+                .setPairFailureCounts(0)
+                .setLogPairWithCachedModelId(true)
+                .setDirectConnectProfileIfModelIdInCache(true)
+                .setCachedDeviceAddress("")
+                .setPossibleCachedDeviceAddress("")
+                .setSameModelIdPairedDeviceCount(0)
+                .setIsDeviceFinishCheckAddressFromCache(true);
+    }
+
+    /**
+     * Preferences builder.
+     */
+    public static class Builder {
+
+        private int mGattOperationTimeoutSeconds;
+        private int mGattConnectionTimeoutSeconds;
+        private int mBluetoothToggleTimeoutSeconds;
+        private int mBluetoothToggleSleepSeconds;
+        private int mClassicDiscoveryTimeoutSeconds;
+        private int mNumDiscoverAttempts;
+        private int mDiscoveryRetrySleepSeconds;
+        private boolean mIgnoreDiscoveryError;
+        private int mSdpTimeoutSeconds;
+        private int mNumSdpAttempts;
+        private int mNumCreateBondAttempts;
+        private int mNumConnectAttempts;
+        private int mNumWriteAccountKeyAttempts;
+        private boolean mToggleBluetoothOnFailure;
+        private boolean mBluetoothStateUsesPolling;
+        private int mBluetoothStatePollingMillis;
+        private int mNumAttempts;
+        private boolean mEnableBrEdrHandover;
+        private short mBrHandoverDataCharacteristicId;
+        private short mBluetoothSigDataCharacteristicId;
+        private short mFirmwareVersionCharacteristicId;
+        private short mBrTransportBlockDataDescriptorId;
+        private boolean mWaitForUuidsAfterBonding;
+        private boolean mReceiveUuidsAndBondedEventBeforeClose;
+        private int mRemoveBondTimeoutSeconds;
+        private int mRemoveBondSleepMillis;
+        private int mCreateBondTimeoutSeconds;
+        private int mHidCreateBondTimeoutSeconds;
+        private int mProxyTimeoutSeconds;
+        private boolean mRejectPhonebookAccess;
+        private boolean mRejectMessageAccess;
+        private boolean mRejectSimAccess;
+        private int mWriteAccountKeySleepMillis;
+        private boolean mSkipDisconnectingGattBeforeWritingAccountKey;
+        private boolean mMoreEventLogForQuality;
+        private boolean mRetryGattConnectionAndSecretHandshake;
+        private long mGattConnectShortTimeoutMs;
+        private long mGattConnectLongTimeoutMs;
+        private long mGattConnectShortTimeoutRetryMaxSpentTimeMs;
+        private long mAddressRotateRetryMaxSpentTimeMs;
+        private long mPairingRetryDelayMs;
+        private long mSecretHandshakeShortTimeoutMs;
+        private long mSecretHandshakeLongTimeoutMs;
+        private long mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs;
+        private long mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs;
+        private long mSecretHandshakeRetryAttempts;
+        private long mSecretHandshakeRetryGattConnectionMaxSpentTimeMs;
+        private long mSignalLostRetryMaxSpentTimeMs;
+        private ImmutableSet<Integer> mGattConnectionAndSecretHandshakeNoRetryGattError;
+        private boolean mRetrySecretHandshakeTimeout;
+        private boolean mLogUserManualRetry;
+        private int mPairFailureCounts;
+        private String mCachedDeviceAddress;
+        private String mPossibleCachedDeviceAddress;
+        private int mSameModelIdPairedDeviceCount;
+        private boolean mIsDeviceFinishCheckAddressFromCache;
+        private boolean mLogPairWithCachedModelId;
+        private boolean mDirectConnectProfileIfModelIdInCache;
+        private boolean mAcceptPasskey;
+        private byte[] mSupportedProfileUuids;
+        private boolean mProviderInitiatesBondingIfSupported;
+        private boolean mAttemptDirectConnectionWhenPreviouslyBonded;
+        private boolean mAutomaticallyReconnectGattWhenNeeded;
+        private boolean mSkipConnectingProfiles;
+        private boolean mIgnoreUuidTimeoutAfterBonded;
+        private boolean mSpecifyCreateBondTransportType;
+        private int mCreateBondTransportType;
+        private boolean mIncreaseIntentFilterPriority;
+        private boolean mEvaluatePerformance;
+        private Preferences.ExtraLoggingInformation mExtraLoggingInformation;
+        private boolean mEnableNamingCharacteristic;
+        private boolean mEnableFirmwareVersionCharacteristic;
+        private boolean mKeepSameAccountKeyWrite;
+        private boolean mIsRetroactivePairing;
+        private int mNumSdpAttemptsAfterBonded;
+        private boolean mSupportHidDevice;
+        private boolean mEnablePairingWhileDirectlyConnecting;
+        private boolean mAcceptConsentForFastPairOne;
+        private int mGattConnectRetryTimeoutMillis;
+        private boolean mEnable128BitCustomGattCharacteristicsId;
+        private boolean mEnableSendExceptionStepToValidator;
+        private boolean mEnableAdditionalDataTypeWhenActionOverBle;
+        private boolean mCheckBondStateWhenSkipConnectingProfiles;
+        private boolean mHandlePasskeyConfirmationByUi;
+        private boolean mEnablePairFlowShowUiWithoutProfileConnection;
+
+        private Builder() {
+        }
+
+        private Builder(Preferences source) {
+            this.mGattOperationTimeoutSeconds = source.getGattOperationTimeoutSeconds();
+            this.mGattConnectionTimeoutSeconds = source.getGattConnectionTimeoutSeconds();
+            this.mBluetoothToggleTimeoutSeconds = source.getBluetoothToggleTimeoutSeconds();
+            this.mBluetoothToggleSleepSeconds = source.getBluetoothToggleSleepSeconds();
+            this.mClassicDiscoveryTimeoutSeconds = source.getClassicDiscoveryTimeoutSeconds();
+            this.mNumDiscoverAttempts = source.getNumDiscoverAttempts();
+            this.mDiscoveryRetrySleepSeconds = source.getDiscoveryRetrySleepSeconds();
+            this.mIgnoreDiscoveryError = source.getIgnoreDiscoveryError();
+            this.mSdpTimeoutSeconds = source.getSdpTimeoutSeconds();
+            this.mNumSdpAttempts = source.getNumSdpAttempts();
+            this.mNumCreateBondAttempts = source.getNumCreateBondAttempts();
+            this.mNumConnectAttempts = source.getNumConnectAttempts();
+            this.mNumWriteAccountKeyAttempts = source.getNumWriteAccountKeyAttempts();
+            this.mToggleBluetoothOnFailure = source.getToggleBluetoothOnFailure();
+            this.mBluetoothStateUsesPolling = source.getBluetoothStateUsesPolling();
+            this.mBluetoothStatePollingMillis = source.getBluetoothStatePollingMillis();
+            this.mNumAttempts = source.getNumAttempts();
+            this.mEnableBrEdrHandover = source.getEnableBrEdrHandover();
+            this.mBrHandoverDataCharacteristicId = source.getBrHandoverDataCharacteristicId();
+            this.mBluetoothSigDataCharacteristicId = source.getBluetoothSigDataCharacteristicId();
+            this.mFirmwareVersionCharacteristicId = source.getFirmwareVersionCharacteristicId();
+            this.mBrTransportBlockDataDescriptorId = source.getBrTransportBlockDataDescriptorId();
+            this.mWaitForUuidsAfterBonding = source.getWaitForUuidsAfterBonding();
+            this.mReceiveUuidsAndBondedEventBeforeClose = source
+                    .getReceiveUuidsAndBondedEventBeforeClose();
+            this.mRemoveBondTimeoutSeconds = source.getRemoveBondTimeoutSeconds();
+            this.mRemoveBondSleepMillis = source.getRemoveBondSleepMillis();
+            this.mCreateBondTimeoutSeconds = source.getCreateBondTimeoutSeconds();
+            this.mHidCreateBondTimeoutSeconds = source.getHidCreateBondTimeoutSeconds();
+            this.mProxyTimeoutSeconds = source.getProxyTimeoutSeconds();
+            this.mRejectPhonebookAccess = source.getRejectPhonebookAccess();
+            this.mRejectMessageAccess = source.getRejectMessageAccess();
+            this.mRejectSimAccess = source.getRejectSimAccess();
+            this.mWriteAccountKeySleepMillis = source.getWriteAccountKeySleepMillis();
+            this.mSkipDisconnectingGattBeforeWritingAccountKey = source
+                    .getSkipDisconnectingGattBeforeWritingAccountKey();
+            this.mMoreEventLogForQuality = source.getMoreEventLogForQuality();
+            this.mRetryGattConnectionAndSecretHandshake = source
+                    .getRetryGattConnectionAndSecretHandshake();
+            this.mGattConnectShortTimeoutMs = source.getGattConnectShortTimeoutMs();
+            this.mGattConnectLongTimeoutMs = source.getGattConnectLongTimeoutMs();
+            this.mGattConnectShortTimeoutRetryMaxSpentTimeMs = source
+                    .getGattConnectShortTimeoutRetryMaxSpentTimeMs();
+            this.mAddressRotateRetryMaxSpentTimeMs = source.getAddressRotateRetryMaxSpentTimeMs();
+            this.mPairingRetryDelayMs = source.getPairingRetryDelayMs();
+            this.mSecretHandshakeShortTimeoutMs = source.getSecretHandshakeShortTimeoutMs();
+            this.mSecretHandshakeLongTimeoutMs = source.getSecretHandshakeLongTimeoutMs();
+            this.mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs = source
+                    .getSecretHandshakeShortTimeoutRetryMaxSpentTimeMs();
+            this.mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs = source
+                    .getSecretHandshakeLongTimeoutRetryMaxSpentTimeMs();
+            this.mSecretHandshakeRetryAttempts = source.getSecretHandshakeRetryAttempts();
+            this.mSecretHandshakeRetryGattConnectionMaxSpentTimeMs = source
+                    .getSecretHandshakeRetryGattConnectionMaxSpentTimeMs();
+            this.mSignalLostRetryMaxSpentTimeMs = source.getSignalLostRetryMaxSpentTimeMs();
+            this.mGattConnectionAndSecretHandshakeNoRetryGattError = source
+                    .getGattConnectionAndSecretHandshakeNoRetryGattError();
+            this.mRetrySecretHandshakeTimeout = source.getRetrySecretHandshakeTimeout();
+            this.mLogUserManualRetry = source.getLogUserManualRetry();
+            this.mPairFailureCounts = source.getPairFailureCounts();
+            this.mCachedDeviceAddress = source.getCachedDeviceAddress();
+            this.mPossibleCachedDeviceAddress = source.getPossibleCachedDeviceAddress();
+            this.mSameModelIdPairedDeviceCount = source.getSameModelIdPairedDeviceCount();
+            this.mIsDeviceFinishCheckAddressFromCache = source
+                    .getIsDeviceFinishCheckAddressFromCache();
+            this.mLogPairWithCachedModelId = source.getLogPairWithCachedModelId();
+            this.mDirectConnectProfileIfModelIdInCache = source
+                    .getDirectConnectProfileIfModelIdInCache();
+            this.mAcceptPasskey = source.getAcceptPasskey();
+            this.mSupportedProfileUuids = source.getSupportedProfileUuids();
+            this.mProviderInitiatesBondingIfSupported = source
+                    .getProviderInitiatesBondingIfSupported();
+            this.mAttemptDirectConnectionWhenPreviouslyBonded = source
+                    .getAttemptDirectConnectionWhenPreviouslyBonded();
+            this.mAutomaticallyReconnectGattWhenNeeded = source
+                    .getAutomaticallyReconnectGattWhenNeeded();
+            this.mSkipConnectingProfiles = source.getSkipConnectingProfiles();
+            this.mIgnoreUuidTimeoutAfterBonded = source.getIgnoreUuidTimeoutAfterBonded();
+            this.mSpecifyCreateBondTransportType = source.getSpecifyCreateBondTransportType();
+            this.mCreateBondTransportType = source.getCreateBondTransportType();
+            this.mIncreaseIntentFilterPriority = source.getIncreaseIntentFilterPriority();
+            this.mEvaluatePerformance = source.getEvaluatePerformance();
+            this.mExtraLoggingInformation = source.getExtraLoggingInformation();
+            this.mEnableNamingCharacteristic = source.getEnableNamingCharacteristic();
+            this.mEnableFirmwareVersionCharacteristic = source
+                    .getEnableFirmwareVersionCharacteristic();
+            this.mKeepSameAccountKeyWrite = source.getKeepSameAccountKeyWrite();
+            this.mIsRetroactivePairing = source.getIsRetroactivePairing();
+            this.mNumSdpAttemptsAfterBonded = source.getNumSdpAttemptsAfterBonded();
+            this.mSupportHidDevice = source.getSupportHidDevice();
+            this.mEnablePairingWhileDirectlyConnecting = source
+                    .getEnablePairingWhileDirectlyConnecting();
+            this.mAcceptConsentForFastPairOne = source.getAcceptConsentForFastPairOne();
+            this.mGattConnectRetryTimeoutMillis = source.getGattConnectRetryTimeoutMillis();
+            this.mEnable128BitCustomGattCharacteristicsId = source
+                    .getEnable128BitCustomGattCharacteristicsId();
+            this.mEnableSendExceptionStepToValidator = source
+                    .getEnableSendExceptionStepToValidator();
+            this.mEnableAdditionalDataTypeWhenActionOverBle = source
+                    .getEnableAdditionalDataTypeWhenActionOverBle();
+            this.mCheckBondStateWhenSkipConnectingProfiles = source
+                    .getCheckBondStateWhenSkipConnectingProfiles();
+            this.mHandlePasskeyConfirmationByUi = source.getHandlePasskeyConfirmationByUi();
+            this.mEnablePairFlowShowUiWithoutProfileConnection = source
+                    .getEnablePairFlowShowUiWithoutProfileConnection();
+        }
+
+        /**
+         * Set gatt operation timeout.
+         */
+        public Builder setGattOperationTimeoutSeconds(int value) {
+            this.mGattOperationTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set gatt connection timeout.
+         */
+        public Builder setGattConnectionTimeoutSeconds(int value) {
+            this.mGattConnectionTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set bluetooth toggle timeout.
+         */
+        public Builder setBluetoothToggleTimeoutSeconds(int value) {
+            this.mBluetoothToggleTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set bluetooth toggle sleep time.
+         */
+        public Builder setBluetoothToggleSleepSeconds(int value) {
+            this.mBluetoothToggleSleepSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set classic discovery timeout.
+         */
+        public Builder setClassicDiscoveryTimeoutSeconds(int value) {
+            this.mClassicDiscoveryTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set number of discover attempts allowed.
+         */
+        public Builder setNumDiscoverAttempts(int value) {
+            this.mNumDiscoverAttempts = value;
+            return this;
+        }
+
+        /**
+         * Set discovery retry sleep time.
+         */
+        public Builder setDiscoveryRetrySleepSeconds(int value) {
+            this.mDiscoveryRetrySleepSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set whether to ignore discovery error.
+         */
+        public Builder setIgnoreDiscoveryError(boolean value) {
+            this.mIgnoreDiscoveryError = value;
+            return this;
+        }
+
+        /**
+         * Set sdp timeout.
+         */
+        public Builder setSdpTimeoutSeconds(int value) {
+            this.mSdpTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set number of sdp attempts allowed.
+         */
+        public Builder setNumSdpAttempts(int value) {
+            this.mNumSdpAttempts = value;
+            return this;
+        }
+
+        /**
+         * Set number of allowed attempts to create bond.
+         */
+        public Builder setNumCreateBondAttempts(int value) {
+            this.mNumCreateBondAttempts = value;
+            return this;
+        }
+
+        /**
+         * Set number of connect attempts allowed.
+         */
+        public Builder setNumConnectAttempts(int value) {
+            this.mNumConnectAttempts = value;
+            return this;
+        }
+
+        /**
+         * Set number of write account key attempts allowed.
+         */
+        public Builder setNumWriteAccountKeyAttempts(int value) {
+            this.mNumWriteAccountKeyAttempts = value;
+            return this;
+        }
+
+        /**
+         * Set whether to retry by bluetooth toggle on failure.
+         */
+        public Builder setToggleBluetoothOnFailure(boolean value) {
+            this.mToggleBluetoothOnFailure = value;
+            return this;
+        }
+
+        /**
+         * Set whether to use polling to set bluetooth status.
+         */
+        public Builder setBluetoothStateUsesPolling(boolean value) {
+            this.mBluetoothStateUsesPolling = value;
+            return this;
+        }
+
+        /**
+         * Set Bluetooth state polling timeout.
+         */
+        public Builder setBluetoothStatePollingMillis(int value) {
+            this.mBluetoothStatePollingMillis = value;
+            return this;
+        }
+
+        /**
+         * Set number of attempts.
+         */
+        public Builder setNumAttempts(int value) {
+            this.mNumAttempts = value;
+            return this;
+        }
+
+        /**
+         * Set whether to enable BrEdr handover.
+         */
+        public Builder setEnableBrEdrHandover(boolean value) {
+            this.mEnableBrEdrHandover = value;
+            return this;
+        }
+
+        /**
+         * Set Br handover data characteristic Id.
+         */
+        public Builder setBrHandoverDataCharacteristicId(short value) {
+            this.mBrHandoverDataCharacteristicId = value;
+            return this;
+        }
+
+        /**
+         * Set Bluetooth Sig data characteristic Id.
+         */
+        public Builder setBluetoothSigDataCharacteristicId(short value) {
+            this.mBluetoothSigDataCharacteristicId = value;
+            return this;
+        }
+
+        /**
+         * Set Firmware version characteristic id.
+         */
+        public Builder setFirmwareVersionCharacteristicId(short value) {
+            this.mFirmwareVersionCharacteristicId = value;
+            return this;
+        }
+
+        /**
+         * Set Br transport block data descriptor id.
+         */
+        public Builder setBrTransportBlockDataDescriptorId(short value) {
+            this.mBrTransportBlockDataDescriptorId = value;
+            return this;
+        }
+
+        /**
+         * Set whether to wait for Uuids after bonding.
+         */
+        public Builder setWaitForUuidsAfterBonding(boolean value) {
+            this.mWaitForUuidsAfterBonding = value;
+            return this;
+        }
+
+        /**
+         * Set whether to receive Uuids and bonded event before close.
+         */
+        public Builder setReceiveUuidsAndBondedEventBeforeClose(boolean value) {
+            this.mReceiveUuidsAndBondedEventBeforeClose = value;
+            return this;
+        }
+
+        /**
+         * Set remove bond timeout.
+         */
+        public Builder setRemoveBondTimeoutSeconds(int value) {
+            this.mRemoveBondTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set remove bound sleep time.
+         */
+        public Builder setRemoveBondSleepMillis(int value) {
+            this.mRemoveBondSleepMillis = value;
+            return this;
+        }
+
+        /**
+         * Set create bond timeout.
+         */
+        public Builder setCreateBondTimeoutSeconds(int value) {
+            this.mCreateBondTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set Hid create bond timeout.
+         */
+        public Builder setHidCreateBondTimeoutSeconds(int value) {
+            this.mHidCreateBondTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set proxy timeout.
+         */
+        public Builder setProxyTimeoutSeconds(int value) {
+            this.mProxyTimeoutSeconds = value;
+            return this;
+        }
+
+        /**
+         * Set whether to reject phone book access.
+         */
+        public Builder setRejectPhonebookAccess(boolean value) {
+            this.mRejectPhonebookAccess = value;
+            return this;
+        }
+
+        /**
+         * Set whether to reject message access.
+         */
+        public Builder setRejectMessageAccess(boolean value) {
+            this.mRejectMessageAccess = value;
+            return this;
+        }
+
+        /**
+         * Set whether to reject slim access.
+         */
+        public Builder setRejectSimAccess(boolean value) {
+            this.mRejectSimAccess = value;
+            return this;
+        }
+
+        /**
+         * Set whether to accept passkey.
+         */
+        public Builder setAcceptPasskey(boolean value) {
+            this.mAcceptPasskey = value;
+            return this;
+        }
+
+        /**
+         * Set supported profile Uuids.
+         */
+        public Builder setSupportedProfileUuids(byte[] value) {
+            this.mSupportedProfileUuids = value;
+            return this;
+        }
+
+        /**
+         * Set whether to collect more event log for quality.
+         */
+        public Builder setMoreEventLogForQuality(boolean value) {
+            this.mMoreEventLogForQuality = value;
+            return this;
+        }
+
+        /**
+         * Set supported profile Uuids.
+         */
+        public Builder setSupportedProfileUuids(short... uuids) {
+            return setSupportedProfileUuids(Bytes.toBytes(ByteOrder.BIG_ENDIAN, uuids));
+        }
+
+        /**
+         * Set write account key sleep time.
+         */
+        public Builder setWriteAccountKeySleepMillis(int value) {
+            this.mWriteAccountKeySleepMillis = value;
+            return this;
+        }
+
+        /**
+         * Set whether to do provider initialized bonding if supported.
+         */
+        public Builder setProviderInitiatesBondingIfSupported(boolean value) {
+            this.mProviderInitiatesBondingIfSupported = value;
+            return this;
+        }
+
+        /**
+         * Set whether to try direct connection when the device is previously bonded.
+         */
+        public Builder setAttemptDirectConnectionWhenPreviouslyBonded(boolean value) {
+            this.mAttemptDirectConnectionWhenPreviouslyBonded = value;
+            return this;
+        }
+
+        /**
+         * Set whether to automatically reconnect gatt when needed.
+         */
+        public Builder setAutomaticallyReconnectGattWhenNeeded(boolean value) {
+            this.mAutomaticallyReconnectGattWhenNeeded = value;
+            return this;
+        }
+
+        /**
+         * Set whether to skip disconnecting gatt before writing account key.
+         */
+        public Builder setSkipDisconnectingGattBeforeWritingAccountKey(boolean value) {
+            this.mSkipDisconnectingGattBeforeWritingAccountKey = value;
+            return this;
+        }
+
+        /**
+         * Set whether to skip connecting profiles.
+         */
+        public Builder setSkipConnectingProfiles(boolean value) {
+            this.mSkipConnectingProfiles = value;
+            return this;
+        }
+
+        /**
+         * Set whether to ignore Uuid timeout after bonded.
+         */
+        public Builder setIgnoreUuidTimeoutAfterBonded(boolean value) {
+            this.mIgnoreUuidTimeoutAfterBonded = value;
+            return this;
+        }
+
+        /**
+         * Set whether to include transport type in create bound request.
+         */
+        public Builder setSpecifyCreateBondTransportType(boolean value) {
+            this.mSpecifyCreateBondTransportType = value;
+            return this;
+        }
+
+        /**
+         * Set transport type used in create bond request.
+         */
+        public Builder setCreateBondTransportType(int value) {
+            this.mCreateBondTransportType = value;
+            return this;
+        }
+
+        /**
+         * Set whether to increase intent filter priority.
+         */
+        public Builder setIncreaseIntentFilterPriority(boolean value) {
+            this.mIncreaseIntentFilterPriority = value;
+            return this;
+        }
+
+        /**
+         * Set whether to evaluate performance.
+         */
+        public Builder setEvaluatePerformance(boolean value) {
+            this.mEvaluatePerformance = value;
+            return this;
+        }
+
+        /**
+         * Set extra logging info.
+         */
+        public Builder setExtraLoggingInformation(ExtraLoggingInformation value) {
+            this.mExtraLoggingInformation = value;
+            return this;
+        }
+
+        /**
+         * Set whether to enable naming characteristic.
+         */
+        public Builder setEnableNamingCharacteristic(boolean value) {
+            this.mEnableNamingCharacteristic = value;
+            return this;
+        }
+
+        /**
+         * Set whether to keep writing the account key to the provider, that has already paired with
+         * the account.
+         */
+        public Builder setKeepSameAccountKeyWrite(boolean value) {
+            this.mKeepSameAccountKeyWrite = value;
+            return this;
+        }
+
+        /**
+         * Set whether to enable firmware version characteristic.
+         */
+        public Builder setEnableFirmwareVersionCharacteristic(boolean value) {
+            this.mEnableFirmwareVersionCharacteristic = value;
+            return this;
+        }
+
+        /**
+         * Set whether it is retroactive pairing.
+         */
+        public Builder setIsRetroactivePairing(boolean value) {
+            this.mIsRetroactivePairing = value;
+            return this;
+        }
+
+        /**
+         * Set number of allowed sdp attempts after bonded.
+         */
+        public Builder setNumSdpAttemptsAfterBonded(int value) {
+            this.mNumSdpAttemptsAfterBonded = value;
+            return this;
+        }
+
+        /**
+         * Set whether to support Hid device.
+         */
+        public Builder setSupportHidDevice(boolean value) {
+            this.mSupportHidDevice = value;
+            return this;
+        }
+
+        /**
+         * Set wehther to enable the pairing behavior to handle the state transition from
+         * BOND_BONDED to BOND_BONDING when directly connecting profiles.
+         */
+        public Builder setEnablePairingWhileDirectlyConnecting(boolean value) {
+            this.mEnablePairingWhileDirectlyConnecting = value;
+            return this;
+        }
+
+        /**
+         * Set whether to accept consent for fast pair one.
+         */
+        public Builder setAcceptConsentForFastPairOne(boolean value) {
+            this.mAcceptConsentForFastPairOne = value;
+            return this;
+        }
+
+        /**
+         * Set Gatt connect retry timeout.
+         */
+        public Builder setGattConnectRetryTimeoutMillis(int value) {
+            this.mGattConnectRetryTimeoutMillis = value;
+            return this;
+        }
+
+        /**
+         * Set whether to enable 128 bit custom gatt characteristic Id.
+         */
+        public Builder setEnable128BitCustomGattCharacteristicsId(boolean value) {
+            this.mEnable128BitCustomGattCharacteristicsId = value;
+            return this;
+        }
+
+        /**
+         * Set whether to send exception step to validator.
+         */
+        public Builder setEnableSendExceptionStepToValidator(boolean value) {
+            this.mEnableSendExceptionStepToValidator = value;
+            return this;
+        }
+
+        /**
+         * Set wehther to add the additional data type in the handshake when action over BLE.
+         */
+        public Builder setEnableAdditionalDataTypeWhenActionOverBle(boolean value) {
+            this.mEnableAdditionalDataTypeWhenActionOverBle = value;
+            return this;
+        }
+
+        /**
+         * Set whether to check bond state when skip connecting profiles.
+         */
+        public Builder setCheckBondStateWhenSkipConnectingProfiles(boolean value) {
+            this.mCheckBondStateWhenSkipConnectingProfiles = value;
+            return this;
+        }
+
+        /**
+         * Set whether to handle passkey confirmation by UI.
+         */
+        public Builder setHandlePasskeyConfirmationByUi(boolean value) {
+            this.mHandlePasskeyConfirmationByUi = value;
+            return this;
+        }
+
+        /**
+         * Set wehther to retry gatt connection and secret handshake.
+         */
+        public Builder setRetryGattConnectionAndSecretHandshake(boolean value) {
+            this.mRetryGattConnectionAndSecretHandshake = value;
+            return this;
+        }
+
+        /**
+         * Set gatt connect short timeout.
+         */
+        public Builder setGattConnectShortTimeoutMs(long value) {
+            this.mGattConnectShortTimeoutMs = value;
+            return this;
+        }
+
+        /**
+         * Set gatt connect long timeout.
+         */
+        public Builder setGattConnectLongTimeoutMs(long value) {
+            this.mGattConnectLongTimeoutMs = value;
+            return this;
+        }
+
+        /**
+         * Set gatt connection short timoutout, including retry.
+         */
+        public Builder setGattConnectShortTimeoutRetryMaxSpentTimeMs(long value) {
+            this.mGattConnectShortTimeoutRetryMaxSpentTimeMs = value;
+            return this;
+        }
+
+        /**
+         * Set address rotate timeout, including retry.
+         */
+        public Builder setAddressRotateRetryMaxSpentTimeMs(long value) {
+            this.mAddressRotateRetryMaxSpentTimeMs = value;
+            return this;
+        }
+
+        /**
+         * Set pairing retry delay time.
+         */
+        public Builder setPairingRetryDelayMs(long value) {
+            this.mPairingRetryDelayMs = value;
+            return this;
+        }
+
+        /**
+         * Set secret handshake short timeout.
+         */
+        public Builder setSecretHandshakeShortTimeoutMs(long value) {
+            this.mSecretHandshakeShortTimeoutMs = value;
+            return this;
+        }
+
+        /**
+         * Set secret handshake long timeout.
+         */
+        public Builder setSecretHandshakeLongTimeoutMs(long value) {
+            this.mSecretHandshakeLongTimeoutMs = value;
+            return this;
+        }
+
+        /**
+         * Set secret handshake short timeout retry max spent time.
+         */
+        public Builder setSecretHandshakeShortTimeoutRetryMaxSpentTimeMs(long value) {
+            this.mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs = value;
+            return this;
+        }
+
+        /**
+         * Set secret handshake long timeout retry max spent time.
+         */
+        public Builder setSecretHandshakeLongTimeoutRetryMaxSpentTimeMs(long value) {
+            this.mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs = value;
+            return this;
+        }
+
+        /**
+         * Set secret handshake retry attempts allowed.
+         */
+        public Builder setSecretHandshakeRetryAttempts(long value) {
+            this.mSecretHandshakeRetryAttempts = value;
+            return this;
+        }
+
+        /**
+         * Set secret handshake retry gatt connection max spent time.
+         */
+        public Builder setSecretHandshakeRetryGattConnectionMaxSpentTimeMs(long value) {
+            this.mSecretHandshakeRetryGattConnectionMaxSpentTimeMs = value;
+            return this;
+        }
+
+        /**
+         * Set signal loss retry max spent time.
+         */
+        public Builder setSignalLostRetryMaxSpentTimeMs(long value) {
+            this.mSignalLostRetryMaxSpentTimeMs = value;
+            return this;
+        }
+
+        /**
+         * Set gatt connection and secret handshake no retry gatt error.
+         */
+        public Builder setGattConnectionAndSecretHandshakeNoRetryGattError(
+                ImmutableSet<Integer> value) {
+            this.mGattConnectionAndSecretHandshakeNoRetryGattError = value;
+            return this;
+        }
+
+        /**
+         * Set retry secret handshake timeout.
+         */
+        public Builder setRetrySecretHandshakeTimeout(boolean value) {
+            this.mRetrySecretHandshakeTimeout = value;
+            return this;
+        }
+
+        /**
+         * Set whether to log user manual retry.
+         */
+        public Builder setLogUserManualRetry(boolean value) {
+            this.mLogUserManualRetry = value;
+            return this;
+        }
+
+        /**
+         * Set pair falure counts.
+         */
+        public Builder setPairFailureCounts(int counts) {
+            this.mPairFailureCounts = counts;
+            return this;
+        }
+
+        /**
+         * Set whether to use pair flow to show ui when pairing is finished without connecting
+         * profile..
+         */
+        public Builder setEnablePairFlowShowUiWithoutProfileConnection(boolean value) {
+            this.mEnablePairFlowShowUiWithoutProfileConnection = value;
+            return this;
+        }
+
+        /**
+         * Set whether to log pairing with cached module Id.
+         */
+        public Builder setLogPairWithCachedModelId(boolean value) {
+            this.mLogPairWithCachedModelId = value;
+            return this;
+        }
+
+        /**
+         * Set possible cached device address.
+         */
+        public Builder setPossibleCachedDeviceAddress(String value) {
+            this.mPossibleCachedDeviceAddress = value;
+            return this;
+        }
+
+        /**
+         * Set paired device count from the same module Id.
+         */
+        public Builder setSameModelIdPairedDeviceCount(int value) {
+            this.mSameModelIdPairedDeviceCount = value;
+            return this;
+        }
+
+        /**
+         * Set whether the bonded device address is from cache.
+         */
+        public Builder setIsDeviceFinishCheckAddressFromCache(boolean value) {
+            this.mIsDeviceFinishCheckAddressFromCache = value;
+            return this;
+        }
+
+        /**
+         * Set whether to directly connect profile if modelId is in cache.
+         */
+        public Builder setDirectConnectProfileIfModelIdInCache(boolean value) {
+            this.mDirectConnectProfileIfModelIdInCache = value;
+            return this;
+        }
+
+        /**
+         * Set cached device address.
+         */
+        public Builder setCachedDeviceAddress(String value) {
+            this.mCachedDeviceAddress = value;
+            return this;
+        }
+
+        /**
+         * Builds a Preferences instance.
+         */
+        public Preferences build() {
+            return new Preferences(
+                    this.mGattOperationTimeoutSeconds,
+                    this.mGattConnectionTimeoutSeconds,
+                    this.mBluetoothToggleTimeoutSeconds,
+                    this.mBluetoothToggleSleepSeconds,
+                    this.mClassicDiscoveryTimeoutSeconds,
+                    this.mNumDiscoverAttempts,
+                    this.mDiscoveryRetrySleepSeconds,
+                    this.mIgnoreDiscoveryError,
+                    this.mSdpTimeoutSeconds,
+                    this.mNumSdpAttempts,
+                    this.mNumCreateBondAttempts,
+                    this.mNumConnectAttempts,
+                    this.mNumWriteAccountKeyAttempts,
+                    this.mToggleBluetoothOnFailure,
+                    this.mBluetoothStateUsesPolling,
+                    this.mBluetoothStatePollingMillis,
+                    this.mNumAttempts,
+                    this.mEnableBrEdrHandover,
+                    this.mBrHandoverDataCharacteristicId,
+                    this.mBluetoothSigDataCharacteristicId,
+                    this.mFirmwareVersionCharacteristicId,
+                    this.mBrTransportBlockDataDescriptorId,
+                    this.mWaitForUuidsAfterBonding,
+                    this.mReceiveUuidsAndBondedEventBeforeClose,
+                    this.mRemoveBondTimeoutSeconds,
+                    this.mRemoveBondSleepMillis,
+                    this.mCreateBondTimeoutSeconds,
+                    this.mHidCreateBondTimeoutSeconds,
+                    this.mProxyTimeoutSeconds,
+                    this.mRejectPhonebookAccess,
+                    this.mRejectMessageAccess,
+                    this.mRejectSimAccess,
+                    this.mWriteAccountKeySleepMillis,
+                    this.mSkipDisconnectingGattBeforeWritingAccountKey,
+                    this.mMoreEventLogForQuality,
+                    this.mRetryGattConnectionAndSecretHandshake,
+                    this.mGattConnectShortTimeoutMs,
+                    this.mGattConnectLongTimeoutMs,
+                    this.mGattConnectShortTimeoutRetryMaxSpentTimeMs,
+                    this.mAddressRotateRetryMaxSpentTimeMs,
+                    this.mPairingRetryDelayMs,
+                    this.mSecretHandshakeShortTimeoutMs,
+                    this.mSecretHandshakeLongTimeoutMs,
+                    this.mSecretHandshakeShortTimeoutRetryMaxSpentTimeMs,
+                    this.mSecretHandshakeLongTimeoutRetryMaxSpentTimeMs,
+                    this.mSecretHandshakeRetryAttempts,
+                    this.mSecretHandshakeRetryGattConnectionMaxSpentTimeMs,
+                    this.mSignalLostRetryMaxSpentTimeMs,
+                    this.mGattConnectionAndSecretHandshakeNoRetryGattError,
+                    this.mRetrySecretHandshakeTimeout,
+                    this.mLogUserManualRetry,
+                    this.mPairFailureCounts,
+                    this.mCachedDeviceAddress,
+                    this.mPossibleCachedDeviceAddress,
+                    this.mSameModelIdPairedDeviceCount,
+                    this.mIsDeviceFinishCheckAddressFromCache,
+                    this.mLogPairWithCachedModelId,
+                    this.mDirectConnectProfileIfModelIdInCache,
+                    this.mAcceptPasskey,
+                    this.mSupportedProfileUuids,
+                    this.mProviderInitiatesBondingIfSupported,
+                    this.mAttemptDirectConnectionWhenPreviouslyBonded,
+                    this.mAutomaticallyReconnectGattWhenNeeded,
+                    this.mSkipConnectingProfiles,
+                    this.mIgnoreUuidTimeoutAfterBonded,
+                    this.mSpecifyCreateBondTransportType,
+                    this.mCreateBondTransportType,
+                    this.mIncreaseIntentFilterPriority,
+                    this.mEvaluatePerformance,
+                    this.mExtraLoggingInformation,
+                    this.mEnableNamingCharacteristic,
+                    this.mEnableFirmwareVersionCharacteristic,
+                    this.mKeepSameAccountKeyWrite,
+                    this.mIsRetroactivePairing,
+                    this.mNumSdpAttemptsAfterBonded,
+                    this.mSupportHidDevice,
+                    this.mEnablePairingWhileDirectlyConnecting,
+                    this.mAcceptConsentForFastPairOne,
+                    this.mGattConnectRetryTimeoutMillis,
+                    this.mEnable128BitCustomGattCharacteristicsId,
+                    this.mEnableSendExceptionStepToValidator,
+                    this.mEnableAdditionalDataTypeWhenActionOverBle,
+                    this.mCheckBondStateWhenSkipConnectingProfiles,
+                    this.mHandlePasskeyConfirmationByUi,
+                    this.mEnablePairFlowShowUiWithoutProfileConnection);
+        }
+    }
+
+    /**
+     * Whether a given Uuid is supported.
+     */
+    public boolean isSupportedProfile(short profileUuid) {
+        return Constants.PROFILES.containsKey(profileUuid)
+                && Shorts.contains(
+                Bytes.toShorts(ByteOrder.BIG_ENDIAN, getSupportedProfileUuids()), profileUuid);
+    }
+
+    /**
+     * Information that will be used for logging.
+     */
+    public static class ExtraLoggingInformation {
+
+        private final String mModelId;
+
+        private ExtraLoggingInformation(String modelId) {
+            this.mModelId = modelId;
+        }
+
+        /**
+         * Returns model Id.
+         */
+        public String getModelId() {
+            return mModelId;
+        }
+
+        /**
+         * Converts an instance to a builder.
+         */
+        public Builder toBuilder() {
+            return new Builder(this);
+        }
+
+        /**
+         * Creates a builder for ExtraLoggingInformation.
+         */
+        public static Builder builder() {
+            return new ExtraLoggingInformation.Builder();
+        }
+
+        @Override
+        public String toString() {
+            return "ExtraLoggingInformation{" + "modelId=" + mModelId + "}";
+        }
+
+        @Override
+        public boolean equals(@Nullable Object o) {
+            if (o == this) {
+                return true;
+            }
+            if (o instanceof ExtraLoggingInformation) {
+                Preferences.ExtraLoggingInformation that = (Preferences.ExtraLoggingInformation) o;
+                return this.mModelId.equals(that.getModelId());
+            }
+            return false;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mModelId);
+        }
+
+        /**
+         * Extra logging information builder.
+         */
+        public static class Builder {
+
+            private String mModelId;
+
+            private Builder() {
+            }
+
+            private Builder(ExtraLoggingInformation source) {
+                this.mModelId = source.getModelId();
+            }
+
+            /**
+             * Set model ID.
+             */
+            public Builder setModelId(String modelId) {
+                this.mModelId = modelId;
+                return this;
+            }
+
+            /**
+             * Builds extra logging information.
+             */
+            public ExtraLoggingInformation build() {
+                return new ExtraLoggingInformation(mModelId);
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Reflect.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Reflect.java
new file mode 100644
index 0000000..a2603b5
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/Reflect.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Utilities for calling methods using reflection. The main benefit of using this helper is to avoid
+ * complications around exception handling when calling methods reflectively. It's not safe to use
+ * Java 8's multicatch on such exceptions, because the java compiler converts multicatch into
+ * ReflectiveOperationException in some instances, which doesn't work on older sdk versions.
+ * Instead, use these utilities and catch ReflectionException.
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * try {
+ *   Reflect.on(btAdapter)
+ *       .withMethod("setScanMode", int.class)
+ *       .invoke(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
+ * } catch (ReflectionException e) { }
+ * }</pre>
+ */
+// TODO(b/202549655): remove existing Reflect usage. New usage is not allowed! No exception!
+public final class Reflect {
+    private final Object mTargetObject;
+
+    private Reflect(Object targetObject) {
+        this.mTargetObject = targetObject;
+    }
+
+    /** Creates an instance of this helper to invoke methods on the given target object. */
+    public static Reflect on(Object targetObject) {
+        return new Reflect(targetObject);
+    }
+
+    /** Finds a method with the given name and parameter types. */
+    public ReflectionMethod withMethod(String methodName, Class<?>... paramTypes)
+            throws ReflectionException {
+        try {
+            return new ReflectionMethod(mTargetObject.getClass().getMethod(methodName, paramTypes));
+        } catch (NoSuchMethodException e) {
+            throw new ReflectionException(e);
+        }
+    }
+
+    /** Represents an invokable method found reflectively. */
+    public final class ReflectionMethod {
+        private final Method mMethod;
+
+        private ReflectionMethod(Method method) {
+            this.mMethod = method;
+        }
+
+        /**
+         * Invokes this instance method with the given parameters. The called method does not return
+         * a value.
+         */
+        public void invoke(Object... parameters) throws ReflectionException {
+            try {
+                mMethod.invoke(mTargetObject, parameters);
+            } catch (IllegalAccessException e) {
+                throw new ReflectionException(e);
+            } catch (InvocationTargetException e) {
+                throw new ReflectionException(e);
+            }
+        }
+
+        /**
+         * Invokes this instance method with the given parameters. The called method returns a non
+         * null value.
+         */
+        public Object get(Object... parameters) throws ReflectionException {
+            Object value;
+            try {
+                value = mMethod.invoke(mTargetObject, parameters);
+            } catch (IllegalAccessException e) {
+                throw new ReflectionException(e);
+            } catch (InvocationTargetException e) {
+                throw new ReflectionException(e);
+            }
+            if (value == null) {
+                throw new ReflectionException(new NullPointerException());
+            }
+            return value;
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ReflectionException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ReflectionException.java
new file mode 100644
index 0000000..1c20c55
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ReflectionException.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+/**
+ * An exception thrown during a reflection operation. Like ReflectiveOperationException, except
+ * compatible on older API versions.
+ */
+public final class ReflectionException extends Exception {
+    ReflectionException(Throwable cause) {
+        super(cause.getMessage(), cause);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SignalLostException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SignalLostException.java
new file mode 100644
index 0000000..244ee66
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SignalLostException.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+/** Base class for fast pair signal lost exceptions. */
+public class SignalLostException extends PairingException {
+    SignalLostException(String message, Exception e) {
+        super(message);
+        initCause(e);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SignalRotatedException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SignalRotatedException.java
new file mode 100644
index 0000000..d0d2a5d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SignalRotatedException.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+/** Base class for fast pair signal rotated exceptions. */
+public class SignalRotatedException extends PairingException {
+    private final String mNewAddress;
+
+    SignalRotatedException(String message, String newAddress, Exception e) {
+        super(message);
+        this.mNewAddress = newAddress;
+        initCause(e);
+    }
+
+    /** Returns the new BLE address for the model ID. */
+    public String getNewAddress() {
+        return mNewAddress;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SimpleBroadcastReceiver.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SimpleBroadcastReceiver.java
new file mode 100644
index 0000000..7f525a7
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SimpleBroadcastReceiver.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.annotation.TargetApi;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.util.Arrays;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Like {@link BroadcastReceiver}, but:
+ *
+ * <ul>
+ *   <li>Simpler to create and register, with a list of actions.
+ *   <li>Implements AutoCloseable. If used as a resource in try-with-resources (available on
+ *       KitKat+), unregisters itself automatically.
+ *   <li>Lets you block waiting for your state transition with {@link #await}.
+ * </ul>
+ */
+// AutoCloseable only available on KitKat+.
+@TargetApi(VERSION_CODES.KITKAT)
+public abstract class SimpleBroadcastReceiver extends BroadcastReceiver implements AutoCloseable {
+
+    private static final String TAG = SimpleBroadcastReceiver.class.getSimpleName();
+
+    /**
+     * Creates a one shot receiver.
+     */
+    public static SimpleBroadcastReceiver oneShotReceiver(
+            Context context, Preferences preferences, String... actions) {
+        return new SimpleBroadcastReceiver(context, preferences, actions) {
+            @Override
+            protected void onReceive(Intent intent) {
+                close();
+            }
+        };
+    }
+
+    private final Context mContext;
+    private final SettableFuture<Void> mIsClosedFuture = SettableFuture.create();
+    private long mAwaitExtendSecond;
+
+    // Nullness checker complains about 'this' being @UnderInitialization
+    @SuppressWarnings("nullness")
+    public SimpleBroadcastReceiver(
+            Context context, Preferences preferences, @Nullable Handler handler,
+            String... actions) {
+        Log.v(TAG, this + " listening for actions " + Arrays.toString(actions));
+        this.mContext = context;
+        IntentFilter intentFilter = new IntentFilter();
+        if (preferences.getIncreaseIntentFilterPriority()) {
+            intentFilter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY);
+        }
+        for (String action : actions) {
+            intentFilter.addAction(action);
+        }
+        context.registerReceiver(this, intentFilter, /* broadcastPermission= */ null, handler);
+    }
+
+    public SimpleBroadcastReceiver(Context context, Preferences preferences, String... actions) {
+        this(context, preferences, /* handler= */ null, actions);
+    }
+
+    /**
+     * Any exception thrown by this method will be delivered via {@link #await}.
+     */
+    protected abstract void onReceive(Intent intent) throws Exception;
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        Log.v(TAG, "Got intent with action= " + intent.getAction());
+        try {
+            onReceive(intent);
+        } catch (Exception e) {
+            closeWithError(e);
+        }
+    }
+
+    @Override
+    public void close() {
+        closeWithError(null);
+    }
+
+    void closeWithError(@Nullable Exception e) {
+        try {
+            mContext.unregisterReceiver(this);
+        } catch (IllegalArgumentException ignored) {
+            // Ignore. Happens if you unregister twice.
+        }
+        if (e == null) {
+            mIsClosedFuture.set(null);
+        } else {
+            mIsClosedFuture.setException(e);
+        }
+    }
+
+    /**
+     * Extends the awaiting time.
+     */
+    public void extendAwaitSecond(int awaitExtendSecond) {
+        this.mAwaitExtendSecond = awaitExtendSecond;
+    }
+
+    /**
+     * Blocks until this receiver has closed (i.e. the state transition that this receiver is
+     * interested in has completed). Throws an exception on any error.
+     */
+    public void await(long timeout, TimeUnit timeUnit)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        Log.v(TAG, this + " waiting on future for " + timeout + " " + timeUnit);
+        try {
+            mIsClosedFuture.get(timeout, timeUnit);
+        } catch (TimeoutException e) {
+            if (mAwaitExtendSecond <= 0) {
+                throw e;
+            }
+            Log.i(TAG, "Extend timeout for " + mAwaitExtendSecond + " seconds");
+            mIsClosedFuture.get(mAwaitExtendSecond, TimeUnit.SECONDS);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/TdsException.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/TdsException.java
new file mode 100644
index 0000000..7382ff3
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/TdsException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.BrEdrHandoverErrorCode;
+
+import com.google.errorprone.annotations.FormatMethod;
+
+/**
+ * Thrown when BR/EDR Handover fails.
+ */
+public class TdsException extends Exception {
+
+    final @BrEdrHandoverErrorCode int mErrorCode;
+
+    @FormatMethod
+    TdsException(@BrEdrHandoverErrorCode int errorCode, String format, Object... objects) {
+        super(String.format(format, objects));
+        this.mErrorCode = errorCode;
+    }
+
+    /** Returns error code. */
+    public @BrEdrHandoverErrorCode int getErrorCode() {
+        return mErrorCode;
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/TimingLogger.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/TimingLogger.java
new file mode 100644
index 0000000..83ee309
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/TimingLogger.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.util.ArrayDeque;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * A profiler for performance metrics.
+ *
+ * <p>This class aim to break down the execution time for each steps of process to figure out the
+ * bottleneck.
+ */
+public class TimingLogger {
+
+    private static final String TAG = TimingLogger.class.getSimpleName();
+
+    /**
+     * The name of this session.
+     */
+    private final String mName;
+
+    private final Preferences mPreference;
+
+    /**
+     * The ordered timing sequence data. It's composed by a paired {@link Timing} generated from
+     * {@link #start} and {@link #end}.
+     */
+    private final List<Timing> mTimings;
+
+    private final long mStartTimestampMs;
+
+    /** Constructor. */
+    public TimingLogger(String name, Preferences mPreference) {
+        this.mName = name;
+        this.mPreference = mPreference;
+        mTimings = new CopyOnWriteArrayList<>();
+        mStartTimestampMs = SystemClock.elapsedRealtime();
+    }
+
+    @VisibleForTesting
+    List<Timing> getTimings() {
+        return mTimings;
+    }
+
+    /**
+     * Start a new paired timing.
+     *
+     * @param label The split name of paired timing.
+     */
+    public void start(String label) {
+        if (mPreference.getEvaluatePerformance()) {
+            mTimings.add(new Timing(label));
+        }
+    }
+
+    /**
+     * End a paired timing.
+     */
+    public void end() {
+        if (mPreference.getEvaluatePerformance()) {
+            mTimings.add(new Timing(Timing.END_LABEL));
+        }
+    }
+
+    /**
+     * Print out the timing data.
+     */
+    public void dump() {
+        if (!mPreference.getEvaluatePerformance()) {
+            return;
+        }
+
+        calculateTiming();
+        Log.i(TAG, mName + "[Exclusive time] / [Total time] ([Timestamp])");
+        int indentCount = 0;
+        for (Timing timing : mTimings) {
+            if (timing.isEndTiming()) {
+                indentCount--;
+                continue;
+            }
+            indentCount++;
+            if (timing.mExclusiveTime == timing.mTotalTime) {
+                Log.i(TAG, getIndentString(indentCount) + timing.mName + " " + timing.mExclusiveTime
+                        + "ms (" + getRelativeTimestamp(timing.getTimestamp()) + ")");
+            } else {
+                Log.i(TAG, getIndentString(indentCount) + timing.mName + " " + timing.mExclusiveTime
+                        + "ms / " + timing.mTotalTime + "ms (" + getRelativeTimestamp(
+                        timing.getTimestamp()) + ")");
+            }
+        }
+        Log.i(TAG, mName + "end, " + getTotalTime() + "ms");
+    }
+
+    private void calculateTiming() {
+        ArrayDeque<Timing> arrayDeque = new ArrayDeque<>();
+        for (Timing timing : mTimings) {
+            if (timing.isStartTiming()) {
+                arrayDeque.addFirst(timing);
+                continue;
+            }
+
+            Timing timingStart = arrayDeque.removeFirst();
+            final long time = timing.mTimestamp - timingStart.mTimestamp;
+            timingStart.mExclusiveTime += time;
+            timingStart.mTotalTime += time;
+            if (!arrayDeque.isEmpty()) {
+                arrayDeque.peekFirst().mExclusiveTime -= time;
+            }
+        }
+    }
+
+    private String getIndentString(int indentCount) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < indentCount; i++) {
+            sb.append("  ");
+        }
+        return sb.toString();
+    }
+
+    private long getRelativeTimestamp(long timestamp) {
+        return timestamp - mTimings.get(0).mTimestamp;
+    }
+
+    @VisibleForTesting
+    long getTotalTime() {
+        return mTimings.get(mTimings.size() - 1).mTimestamp - mTimings.get(0).mTimestamp;
+    }
+
+    /**
+     * Gets the current latency since this object was created.
+     */
+    public long getLatencyMs() {
+        return SystemClock.elapsedRealtime() - mStartTimestampMs;
+    }
+
+    @VisibleForTesting
+    static class Timing {
+
+        private static final String END_LABEL = "END_LABEL";
+
+        /**
+         * The name of this paired timing.
+         */
+        private final String mName;
+
+        /**
+         * System uptime in millisecond.
+         */
+        private final long mTimestamp;
+
+        /**
+         * The execution time exclude inner split timings.
+         */
+        private long mExclusiveTime;
+
+        /**
+         * The execution time within a start and an end timing.
+         */
+        private long mTotalTime;
+
+        private Timing(String name) {
+            this.mName = name;
+            mTimestamp = SystemClock.elapsedRealtime();
+            mExclusiveTime = 0;
+            mTotalTime = 0;
+        }
+
+        @VisibleForTesting
+        String getName() {
+            return mName;
+        }
+
+        @VisibleForTesting
+        long getTimestamp() {
+            return mTimestamp;
+        }
+
+        @VisibleForTesting
+        long getExclusiveTime() {
+            return mExclusiveTime;
+        }
+
+        @VisibleForTesting
+        long getTotalTime() {
+            return mTotalTime;
+        }
+
+        @VisibleForTesting
+        boolean isStartTiming() {
+            return !isEndTiming();
+        }
+
+        @VisibleForTesting
+        boolean isEndTiming() {
+            return END_LABEL.equals(mName);
+        }
+    }
+
+    /**
+     * This class ensures each split timing is paired with a start and an end timing.
+     */
+    public static class ScopedTiming implements AutoCloseable {
+
+        private final TimingLogger mTimingLogger;
+
+        public ScopedTiming(TimingLogger logger, String label) {
+            mTimingLogger = logger;
+            mTimingLogger.start(label);
+        }
+
+        @Override
+        public void close() {
+            mTimingLogger.end();
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ToggleBluetoothTask.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ToggleBluetoothTask.java
new file mode 100644
index 0000000..41ac9f5
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/ToggleBluetoothTask.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/** Task for toggling Bluetooth on and back off again. */
+interface ToggleBluetoothTask {
+
+    /**
+     * Toggles the bluetooth adapter off and back on again to help improve connection reliability.
+     *
+     * @throws InterruptedException when waiting for the bluetooth adapter's state to be set has
+     *     been interrupted.
+     * @throws ExecutionException when waiting for the bluetooth adapter's state to be set has
+     *     failed.
+     * @throws TimeoutException when the bluetooth adapter's state fails to be set on or off.
+     */
+    void toggleBluetooth() throws InterruptedException, ExecutionException, TimeoutException;
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnection.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnection.java
new file mode 100644
index 0000000..de131e4
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnection.java
@@ -0,0 +1,781 @@
+/*
+ * Copyright 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.common.bluetooth.gatt;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothStatusCodes;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.nearby.common.bluetooth.BluetoothConsts;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.BluetoothTimeoutException;
+import com.android.server.nearby.common.bluetooth.ReservedUuids;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.ConnectionOptions;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.OperationType;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattWrapper;
+import com.android.server.nearby.common.bluetooth.util.BluetoothGattUtils;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.SynchronousOperation;
+
+import com.google.common.base.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.BlockingDeque;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Gatt connection to a Bluetooth device.
+ */
+public class BluetoothGattConnection implements AutoCloseable {
+
+    private static final String TAG = BluetoothGattConnection.class.getSimpleName();
+
+    @VisibleForTesting
+    static final long OPERATION_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(1);
+    @VisibleForTesting
+    static final long SLOW_OPERATION_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
+
+    @VisibleForTesting
+    static final int GATT_INTERNAL_ERROR = 129;
+    @VisibleForTesting
+    static final int GATT_ERROR = 133;
+
+    private final BluetoothGattWrapper mGatt;
+    private final BluetoothOperationExecutor mBluetoothOperationExecutor;
+    private final ConnectionOptions mConnectionOptions;
+
+    private volatile boolean mServicesDiscovered = false;
+
+    private volatile boolean mIsConnected = false;
+
+    private volatile int mMtu = BluetoothConsts.DEFAULT_MTU;
+
+    private final ConcurrentMap<BluetoothGattCharacteristic, ChangeObserver> mChangeObservers =
+            new ConcurrentHashMap<>();
+
+    private final List<ConnectionCloseListener> mCloseListeners = new ArrayList<>();
+
+    private long mOperationTimeoutMillis = OPERATION_TIMEOUT_MILLIS;
+
+    BluetoothGattConnection(
+            BluetoothGattWrapper gatt,
+            BluetoothOperationExecutor bluetoothOperationExecutor,
+            ConnectionOptions connectionOptions) {
+        mGatt = gatt;
+        mBluetoothOperationExecutor = bluetoothOperationExecutor;
+        mConnectionOptions = connectionOptions;
+    }
+
+    /**
+     * Set operation timeout.
+     */
+    public void setOperationTimeout(long timeoutMillis) {
+        Preconditions.checkArgument(timeoutMillis > 0, "invalid time out value");
+        mOperationTimeoutMillis = timeoutMillis;
+    }
+
+    /**
+     * Returns connected device.
+     */
+    public BluetoothDevice getDevice() {
+        return mGatt.getDevice();
+    }
+
+    public ConnectionOptions getConnectionOptions() {
+        return mConnectionOptions;
+    }
+
+    public boolean isConnected() {
+        return mIsConnected;
+    }
+
+    /**
+     * Get service.
+     */
+    public BluetoothGattService getService(UUID uuid) throws BluetoothException {
+        Log.d(TAG, String.format("Getting service %s.", uuid));
+        if (!mServicesDiscovered) {
+            discoverServices();
+        }
+        BluetoothGattService match = null;
+        for (BluetoothGattService service : mGatt.getServices()) {
+            if (service.getUuid().equals(uuid)) {
+                if (match != null) {
+                    throw new BluetoothException(
+                            String.format("More than one service %s found on device %s.",
+                                    uuid,
+                                    mGatt.getDevice()));
+                }
+                match = service;
+            }
+        }
+        if (match == null) {
+            throw new BluetoothException(String.format("Service %s not found on device %s.",
+                    uuid,
+                    mGatt.getDevice()));
+        }
+        Log.d(TAG, "Service found.");
+        return match;
+    }
+
+    /**
+     * Returns a list of all characteristics under a given service UUID.
+     */
+    private List<BluetoothGattCharacteristic> getCharacteristics(UUID serviceUuid)
+            throws BluetoothException {
+        if (!mServicesDiscovered) {
+            discoverServices();
+        }
+        ArrayList<BluetoothGattCharacteristic> characteristics = new ArrayList<>();
+        for (BluetoothGattService service : mGatt.getServices()) {
+            // Add all characteristics under this service if its service UUID matches.
+            if (service.getUuid().equals(serviceUuid)) {
+                characteristics.addAll(service.getCharacteristics());
+            }
+        }
+        return characteristics;
+    }
+
+    /**
+     * Get characteristic.
+     */
+    public BluetoothGattCharacteristic getCharacteristic(UUID serviceUuid,
+            UUID characteristicUuid) throws BluetoothException {
+        Log.d(TAG, String.format("Getting characteristic %s on service %s.", characteristicUuid,
+                serviceUuid));
+        BluetoothGattCharacteristic match = null;
+        for (BluetoothGattCharacteristic characteristic : getCharacteristics(serviceUuid)) {
+            if (characteristic.getUuid().equals(characteristicUuid)) {
+                if (match != null) {
+                    throw new BluetoothException(String.format(
+                            "More than one characteristic %s found on service %s on device %s.",
+                            characteristicUuid,
+                            serviceUuid,
+                            mGatt.getDevice()));
+                }
+                match = characteristic;
+            }
+        }
+        if (match == null) {
+            throw new BluetoothException(String.format(
+                    "Characteristic %s not found on service %s of device %s.",
+                    characteristicUuid,
+                    serviceUuid,
+                    mGatt.getDevice()));
+        }
+        Log.d(TAG, "Characteristic found.");
+        return match;
+    }
+
+    /**
+     * Get descriptor.
+     */
+    public BluetoothGattDescriptor getDescriptor(UUID serviceUuid,
+            UUID characteristicUuid, UUID descriptorUuid) throws BluetoothException {
+        Log.d(TAG, String.format("Getting descriptor %s on characteristic %s on service %s.",
+                descriptorUuid, characteristicUuid, serviceUuid));
+        BluetoothGattDescriptor match = null;
+        for (BluetoothGattDescriptor descriptor :
+                getCharacteristic(serviceUuid, characteristicUuid).getDescriptors()) {
+            if (descriptor.getUuid().equals(descriptorUuid)) {
+                if (match != null) {
+                    throw new BluetoothException(String.format("More than one descriptor %s found "
+                                    + "on characteristic %s service %s on device %s.",
+                            descriptorUuid,
+                            characteristicUuid,
+                            serviceUuid,
+                            mGatt.getDevice()));
+                }
+                match = descriptor;
+            }
+        }
+        if (match == null) {
+            throw new BluetoothException(String.format(
+                    "Descriptor %s not found on characteristic %s on service %s of device %s.",
+                    descriptorUuid,
+                    characteristicUuid,
+                    serviceUuid,
+                    mGatt.getDevice()));
+        }
+        Log.d(TAG, "Descriptor found.");
+        return match;
+    }
+
+    /**
+     * Discover services.
+     */
+    public void discoverServices() throws BluetoothException {
+        mBluetoothOperationExecutor.execute(
+                new SynchronousOperation<Void>(OperationType.DISCOVER_SERVICES) {
+                    @Nullable
+                    @Override
+                    public Void call() throws BluetoothException {
+                        if (mServicesDiscovered) {
+                            return null;
+                        }
+                        boolean forceRefresh = false;
+                        try {
+                            discoverServicesInternal();
+                        } catch (BluetoothException e) {
+                            if (!(e instanceof BluetoothGattException)) {
+                                throw e;
+                            }
+                            int errorCode = ((BluetoothGattException) e).getGattErrorCode();
+                            if (errorCode != GATT_ERROR && errorCode != GATT_INTERNAL_ERROR) {
+                                throw e;
+                            }
+                            Log.e(TAG, e.getMessage()
+                                    + "\n Ignore the gatt error for post MNC apis and force "
+                                    + "a refresh");
+                            forceRefresh = true;
+                        }
+
+                        forceRefreshServiceCacheIfNeeded(forceRefresh);
+
+                        mServicesDiscovered = true;
+
+                        return null;
+                    }
+                });
+    }
+
+    private void discoverServicesInternal() throws BluetoothException {
+        Log.i(TAG, "Starting services discovery.");
+        long startTimeMillis = System.currentTimeMillis();
+        try {
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.DISCOVER_SERVICES_INTERNAL, mGatt) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.discoverServices();
+                            if (!success) {
+                                throw new BluetoothException(
+                                        "gatt.discoverServices returned false.");
+                            }
+                        }
+                    },
+                    SLOW_OPERATION_TIMEOUT_MILLIS);
+            Log.i(TAG, String.format("Services discovered successfully in %s ms.",
+                    System.currentTimeMillis() - startTimeMillis));
+        } catch (BluetoothException e) {
+            if (e instanceof BluetoothGattException) {
+                throw new BluetoothGattException(String.format(
+                        "Failed to discover services on device: %s.",
+                        mGatt.getDevice()), ((BluetoothGattException) e).getGattErrorCode(), e);
+            } else {
+                throw new BluetoothException(String.format(
+                        "Failed to discover services on device: %s.",
+                        mGatt.getDevice()), e);
+            }
+        }
+    }
+
+    private boolean hasDynamicServices() {
+        BluetoothGattService gattService =
+                mGatt.getService(ReservedUuids.Services.GENERIC_ATTRIBUTE);
+        if (gattService != null) {
+            BluetoothGattCharacteristic serviceChange =
+                    gattService.getCharacteristic(ReservedUuids.Characteristics.SERVICE_CHANGE);
+            if (serviceChange != null) {
+                return true;
+            }
+        }
+
+        // Check whether the server contains a self defined service dynamic characteristic.
+        gattService = mGatt.getService(BluetoothConsts.SERVICE_DYNAMIC_SERVICE);
+        if (gattService != null) {
+            BluetoothGattCharacteristic serviceChange =
+                    gattService.getCharacteristic(BluetoothConsts.SERVICE_DYNAMIC_CHARACTERISTIC);
+            if (serviceChange != null) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private void forceRefreshServiceCacheIfNeeded(boolean forceRefresh) throws BluetoothException {
+        if (mGatt.getDevice().getBondState() != BluetoothDevice.BOND_BONDED) {
+            // Device is not bonded, so services should not have been cached.
+            return;
+        }
+
+        if (!forceRefresh && !hasDynamicServices()) {
+            return;
+        }
+        Log.i(TAG, "Forcing a refresh of local cache of GATT services");
+        boolean success = mGatt.refresh();
+        if (!success) {
+            throw new BluetoothException("gatt.refresh returned false.");
+        }
+        discoverServicesInternal();
+    }
+
+    /**
+     * Read characteristic.
+     */
+    public byte[] readCharacteristic(UUID serviceUuid, UUID characteristicUuid)
+            throws BluetoothException {
+        return readCharacteristic(getCharacteristic(serviceUuid, characteristicUuid));
+    }
+
+    /**
+     * Read characteristic.
+     */
+    public byte[] readCharacteristic(final BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        try {
+            return mBluetoothOperationExecutor.executeNonnull(
+                    new Operation<byte[]>(OperationType.READ_CHARACTERISTIC, mGatt,
+                            characteristic) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.readCharacteristic(characteristic);
+                            if (!success) {
+                                throw new BluetoothException(
+                                        "gatt.readCharacteristic returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to read %s on device %s.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()), e);
+        }
+    }
+
+    /**
+     * Writes Characteristic.
+     */
+    public void writeCharacteristic(UUID serviceUuid, UUID characteristicUuid, byte[] value)
+            throws BluetoothException {
+        writeCharacteristic(getCharacteristic(serviceUuid, characteristicUuid), value);
+    }
+
+    /**
+     * Writes Characteristic.
+     */
+    public void writeCharacteristic(final BluetoothGattCharacteristic characteristic,
+            final byte[] value) throws BluetoothException {
+        Log.d(TAG, String.format("Writing %d bytes on %s on device %s.",
+                value.length,
+                BluetoothGattUtils.toString(characteristic),
+                mGatt.getDevice()));
+        if ((characteristic.getProperties() & (BluetoothGattCharacteristic.PROPERTY_WRITE
+                | BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)) == 0) {
+            throw new BluetoothException(String.format("%s is not writable!", characteristic));
+        }
+        try {
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.WRITE_CHARACTERISTIC, mGatt, characteristic) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            int writeCharacteristicResponseCode = mGatt.writeCharacteristic(
+                                    characteristic, value, characteristic.getWriteType());
+                            if (writeCharacteristicResponseCode != BluetoothStatusCodes.SUCCESS) {
+                                throw new BluetoothException(
+                                        "gatt.writeCharacteristic returned "
+                                        + writeCharacteristicResponseCode);
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to write %s on device %s.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()), e);
+        }
+        Log.d(TAG, "Writing characteristic done.");
+    }
+
+    /**
+     * Reads descriptor.
+     */
+    public byte[] readDescriptor(UUID serviceUuid, UUID characteristicUuid, UUID descriptorUuid)
+            throws BluetoothException {
+        return readDescriptor(getDescriptor(serviceUuid, characteristicUuid, descriptorUuid));
+    }
+
+    /**
+     * Reads descriptor.
+     */
+    public byte[] readDescriptor(final BluetoothGattDescriptor descriptor)
+            throws BluetoothException {
+        try {
+            return mBluetoothOperationExecutor.executeNonnull(
+                    new Operation<byte[]>(OperationType.READ_DESCRIPTOR, mGatt, descriptor) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.readDescriptor(descriptor);
+                            if (!success) {
+                                throw new BluetoothException("gatt.readDescriptor returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to read %s on %s on device %s.",
+                    descriptor.getUuid(),
+                    BluetoothGattUtils.toString(descriptor),
+                    mGatt.getDevice()), e);
+        }
+    }
+
+    /**
+     * Writes descriptor.
+     */
+    public void writeDescriptor(UUID serviceUuid, UUID characteristicUuid, UUID descriptorUuid,
+            byte[] value) throws BluetoothException {
+        writeDescriptor(getDescriptor(serviceUuid, characteristicUuid, descriptorUuid), value);
+    }
+
+    /**
+     * Writes descriptor.
+     */
+    public void writeDescriptor(final BluetoothGattDescriptor descriptor, final byte[] value)
+            throws BluetoothException {
+        Log.d(TAG, String.format(
+                "Writing %d bytes on %s on device %s.",
+                value.length,
+                BluetoothGattUtils.toString(descriptor),
+                mGatt.getDevice()));
+        long startTimeMillis = System.currentTimeMillis();
+        try {
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.WRITE_DESCRIPTOR, mGatt, descriptor) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            int writeDescriptorResponseCode = mGatt.writeDescriptor(descriptor,
+                                    value);
+                            if (writeDescriptorResponseCode != BluetoothStatusCodes.SUCCESS) {
+                                throw new BluetoothException(
+                                        "gatt.writeDescriptor returned "
+                                        + writeDescriptorResponseCode);
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+            Log.d(TAG, String.format("Writing descriptor done in %s ms.",
+                    System.currentTimeMillis() - startTimeMillis));
+        } catch (BluetoothException e) {
+            throw new BluetoothException(String.format(
+                    "Failed to write %s on device %s.",
+                    BluetoothGattUtils.toString(descriptor),
+                    mGatt.getDevice()), e);
+        }
+    }
+
+    /**
+     * Reads remote Rssi.
+     */
+    public int readRemoteRssi() throws BluetoothException {
+        try {
+            return mBluetoothOperationExecutor.executeNonnull(
+                    new Operation<Integer>(OperationType.READ_RSSI, mGatt) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            boolean success = mGatt.readRemoteRssi();
+                            if (!success) {
+                                throw new BluetoothException("gatt.readRemoteRssi returned false.");
+                            }
+                        }
+                    },
+                    mOperationTimeoutMillis);
+        } catch (BluetoothException e) {
+            throw new BluetoothException(
+                    String.format("Failed to read rssi on device %s.", mGatt.getDevice()), e);
+        }
+    }
+
+    public int getMtu() {
+        return mMtu;
+    }
+
+    /**
+     * Get max data packet size.
+     */
+    public int getMaxDataPacketSize() {
+        // Per BT specs (3.2.9), only MTU - 3 bytes can be used to transmit data
+        return mMtu - 3;
+    }
+
+    /** Set notification enabled or disabled. */
+    @VisibleForTesting
+    public void setNotificationEnabled(BluetoothGattCharacteristic characteristic, boolean enabled)
+            throws BluetoothException {
+        boolean isIndication;
+        int properties = characteristic.getProperties();
+        if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) {
+            isIndication = false;
+        } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
+            isIndication = true;
+        } else {
+            throw new BluetoothException(String.format(
+                    "%s on device %s supports neither notifications nor indications.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()));
+        }
+        BluetoothGattDescriptor clientConfigDescriptor =
+                characteristic.getDescriptor(
+                        ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+        if (clientConfigDescriptor == null) {
+            throw new BluetoothException(String.format(
+                    "%s on device %s is missing client config descriptor.",
+                    BluetoothGattUtils.toString(characteristic),
+                    mGatt.getDevice()));
+        }
+        long startTime = System.currentTimeMillis();
+        Log.d(TAG, String.format("%s %s on characteristic %s.", enabled ? "Enabling" : "Disabling",
+                isIndication ? "indication" : "notification", characteristic.getUuid()));
+
+        if (enabled) {
+            mGatt.setCharacteristicNotification(characteristic, enabled);
+        }
+        writeDescriptor(clientConfigDescriptor,
+                enabled
+                        ? (isIndication
+                        ? BluetoothGattDescriptor.ENABLE_INDICATION_VALUE :
+                        BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
+                        : BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE);
+        if (!enabled) {
+            mGatt.setCharacteristicNotification(characteristic, enabled);
+        }
+
+        Log.d(TAG, String.format("Done in %d ms.", System.currentTimeMillis() - startTime));
+    }
+
+    /**
+     * Enables notification.
+     */
+    public ChangeObserver enableNotification(UUID serviceUuid, UUID characteristicUuid)
+            throws BluetoothException {
+        return enableNotification(getCharacteristic(serviceUuid, characteristicUuid));
+    }
+
+    /**
+     * Enables notification.
+     */
+    public ChangeObserver enableNotification(final BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        return mBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<ChangeObserver>(
+                        OperationType.NOTIFICATION_CHANGE,
+                        characteristic) {
+                    @Override
+                    public ChangeObserver call() throws BluetoothException {
+                        ChangeObserver changeObserver = new ChangeObserver();
+                        mChangeObservers.put(characteristic, changeObserver);
+                        setNotificationEnabled(characteristic, true);
+                        return changeObserver;
+                    }
+                });
+    }
+
+    /**
+     * Disables notification.
+     */
+    public void disableNotification(UUID serviceUuid, UUID characteristicUuid)
+            throws BluetoothException {
+        disableNotification(getCharacteristic(serviceUuid, characteristicUuid));
+    }
+
+    /**
+     * Disables notification.
+     */
+    public void disableNotification(final BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        mBluetoothOperationExecutor.execute(
+                new SynchronousOperation<Void>(
+                        OperationType.NOTIFICATION_CHANGE,
+                        characteristic) {
+                    @Nullable
+                    @Override
+                    public Void call() throws BluetoothException {
+                        setNotificationEnabled(characteristic, false);
+                        mChangeObservers.remove(characteristic);
+                        return null;
+                    }
+                });
+    }
+
+    /**
+     * Adds a close listener.
+     */
+    public void addCloseListener(ConnectionCloseListener listener) {
+        mCloseListeners.add(listener);
+        if (!mIsConnected) {
+            listener.onClose();
+            return;
+        }
+    }
+
+    /**
+     * Removes a close listener.
+     */
+    public void removeCloseListener(ConnectionCloseListener listener) {
+        mCloseListeners.remove(listener);
+    }
+
+    /** onCharacteristicChanged callback. */
+    public void onCharacteristicChanged(BluetoothGattCharacteristic characteristic, byte[] value) {
+        ChangeObserver observer = mChangeObservers.get(characteristic);
+        if (observer == null) {
+            return;
+        }
+        observer.onValueChange(value);
+    }
+
+    @Override
+    public void close() throws BluetoothException {
+        Log.d(TAG, "close");
+        try {
+            if (!mIsConnected) {
+                // Don't call disconnect on a closed connection, since Android framework won't
+                // provide any feedback.
+                return;
+            }
+            mBluetoothOperationExecutor.execute(
+                    new Operation<Void>(OperationType.DISCONNECT, mGatt.getDevice()) {
+                        @Override
+                        public void run() throws BluetoothException {
+                            mGatt.disconnect();
+                        }
+                    }, mOperationTimeoutMillis);
+        } finally {
+            mGatt.close();
+        }
+    }
+
+    /** onConnected callback. */
+    public void onConnected() {
+        Log.d(TAG, "onConnected");
+        mIsConnected = true;
+    }
+
+    /** onClosed callback. */
+    public void onClosed() {
+        Log.d(TAG, "onClosed");
+        if (!mIsConnected) {
+            return;
+        }
+        mIsConnected = false;
+        for (ConnectionCloseListener listener : mCloseListeners) {
+            listener.onClose();
+        }
+        mGatt.close();
+    }
+
+    /**
+     * Observer to wait or be called back when value change.
+     */
+    public static class ChangeObserver {
+
+        private final BlockingDeque<byte[]> mValues = new LinkedBlockingDeque<byte[]>();
+
+        @GuardedBy("mValues")
+        @Nullable
+        private volatile CharacteristicChangeListener mListener;
+
+        /**
+         * Set listener.
+         */
+        public void setListener(@Nullable CharacteristicChangeListener listener) {
+            synchronized (mValues) {
+                mListener = listener;
+                if (listener != null) {
+                    byte[] value;
+                    while ((value = mValues.poll()) != null) {
+                        listener.onValueChange(value);
+                    }
+                }
+            }
+        }
+
+        /**
+         * onValueChange callback.
+         */
+        public void onValueChange(byte[] newValue) {
+            synchronized (mValues) {
+                CharacteristicChangeListener listener = mListener;
+                if (listener == null) {
+                    mValues.add(newValue);
+                } else {
+                    listener.onValueChange(newValue);
+                }
+            }
+        }
+
+        /**
+         * Waits for update for a given time.
+         */
+        public byte[] waitForUpdate(long timeoutMillis) throws BluetoothException {
+            byte[] result;
+            try {
+                result = mValues.poll(timeoutMillis, TimeUnit.MILLISECONDS);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new BluetoothException("Operation interrupted.");
+            }
+            if (result == null) {
+                throw new BluetoothTimeoutException(
+                        String.format("Operation timed out after %dms", timeoutMillis));
+            }
+            return result;
+        }
+    }
+
+    /**
+     * Listener for characteristic data changes over notifications or indications.
+     */
+    public interface CharacteristicChangeListener {
+
+        /**
+         * onValueChange callback.
+         */
+        void onValueChange(byte[] newValue);
+    }
+
+    /**
+     * Listener for connection close events.
+     */
+    public interface ConnectionCloseListener {
+
+        /**
+         * onClose callback.
+         */
+        void onClose();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelper.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelper.java
new file mode 100644
index 0000000..18a9f5f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelper.java
@@ -0,0 +1,690 @@
+/*
+ * Copyright 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.common.bluetooth.gatt;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+import android.util.Log;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattCallback;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattWrapper;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.BluetoothLeScanner;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanCallback;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanResult;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+
+import com.google.common.base.Preconditions;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Wrapper of {@link BluetoothGattWrapper} that provides blocking methods, errors and timeout
+ * handling.
+ */
+@SuppressWarnings("Guava") // java.util.Optional is not available until API 24
+public class BluetoothGattHelper {
+
+    private static final String TAG = BluetoothGattHelper.class.getSimpleName();
+
+    @VisibleForTesting
+    static final long LOW_LATENCY_SCAN_MILLIS = TimeUnit.SECONDS.toMillis(5);
+    private static final long POLL_INTERVAL_MILLIS = 5L /* milliseconds */;
+
+    /**
+     * BT operation types that can be in flight.
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    OperationType.SCAN,
+                    OperationType.CONNECT,
+                    OperationType.DISCOVER_SERVICES,
+                    OperationType.DISCOVER_SERVICES_INTERNAL,
+                    OperationType.NOTIFICATION_CHANGE,
+                    OperationType.READ_CHARACTERISTIC,
+                    OperationType.WRITE_CHARACTERISTIC,
+                    OperationType.READ_DESCRIPTOR,
+                    OperationType.WRITE_DESCRIPTOR,
+                    OperationType.READ_RSSI,
+                    OperationType.WRITE_RELIABLE,
+                    OperationType.CHANGE_MTU,
+                    OperationType.DISCONNECT,
+            })
+    public @interface OperationType {
+        int SCAN = 0;
+        int CONNECT = 1;
+        int DISCOVER_SERVICES = 2;
+        int DISCOVER_SERVICES_INTERNAL = 3;
+        int NOTIFICATION_CHANGE = 4;
+        int READ_CHARACTERISTIC = 5;
+        int WRITE_CHARACTERISTIC = 6;
+        int READ_DESCRIPTOR = 7;
+        int WRITE_DESCRIPTOR = 8;
+        int READ_RSSI = 9;
+        int WRITE_RELIABLE = 10;
+        int CHANGE_MTU = 11;
+        int DISCONNECT = 12;
+    }
+
+    @VisibleForTesting
+    final ScanCallback mScanCallback = new InternalScanCallback();
+    @VisibleForTesting
+    final BluetoothGattCallback mBluetoothGattCallback =
+            new InternalBluetoothGattCallback();
+    @VisibleForTesting
+    final ConcurrentMap<BluetoothGattWrapper, BluetoothGattConnection> mConnections =
+            new ConcurrentHashMap<>();
+
+    private final Context mApplicationContext;
+    private final BluetoothAdapter mBluetoothAdapter;
+    private final BluetoothOperationExecutor mBluetoothOperationExecutor;
+
+    @VisibleForTesting
+    BluetoothGattHelper(
+            Context applicationContext,
+            BluetoothAdapter bluetoothAdapter,
+            BluetoothOperationExecutor bluetoothOperationExecutor) {
+        mApplicationContext = applicationContext;
+        mBluetoothAdapter = bluetoothAdapter;
+        mBluetoothOperationExecutor = bluetoothOperationExecutor;
+    }
+
+    public BluetoothGattHelper(Context applicationContext, BluetoothAdapter bluetoothAdapter) {
+        this(
+                Preconditions.checkNotNull(applicationContext),
+                Preconditions.checkNotNull(bluetoothAdapter),
+                new BluetoothOperationExecutor(5));
+    }
+
+    /**
+     * Auto-connects a serice Uuid.
+     */
+    public BluetoothGattConnection autoConnect(final UUID serviceUuid) throws BluetoothException {
+        Log.d(TAG, String.format("Starting autoconnection to a device advertising service %s.",
+                serviceUuid));
+        BluetoothDevice device = null;
+        int retries = 3;
+        final BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner();
+        if (scanner == null) {
+            throw new BluetoothException("Bluetooth is disabled or LE is not supported.");
+        }
+        final ScanFilter serviceFilter = new ScanFilter.Builder()
+                .setServiceUuid(new ParcelUuid(serviceUuid))
+                .build();
+        ScanSettings.Builder scanSettingsBuilder = new ScanSettings.Builder()
+                .setReportDelay(0);
+        final ScanSettings scanSettingsLowLatency = scanSettingsBuilder
+                .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+                .build();
+        final ScanSettings scanSettingsLowPower = scanSettingsBuilder
+                .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
+                .build();
+        while (true) {
+            long startTimeMillis = System.currentTimeMillis();
+            try {
+                Log.d(TAG, "Starting low latency scanning.");
+                device =
+                        mBluetoothOperationExecutor.executeNonnull(
+                                new Operation<BluetoothDevice>(OperationType.SCAN) {
+                                    @Override
+                                    public void run() throws BluetoothException {
+                                        scanner.startScan(Arrays.asList(serviceFilter),
+                                                scanSettingsLowLatency, mScanCallback);
+                                    }
+                                }, LOW_LATENCY_SCAN_MILLIS);
+            } catch (BluetoothOperationTimeoutException e) {
+                Log.d(TAG, String.format(
+                        "Cannot find a nearby device in low latency scanning after %s ms.",
+                        LOW_LATENCY_SCAN_MILLIS));
+            } finally {
+                scanner.stopScan(mScanCallback);
+            }
+            if (device == null) {
+                Log.d(TAG, "Starting low power scanning.");
+                try {
+                    device = mBluetoothOperationExecutor.executeNonnull(
+                            new Operation<BluetoothDevice>(OperationType.SCAN) {
+                                @Override
+                                public void run() throws BluetoothException {
+                                    scanner.startScan(Arrays.asList(serviceFilter),
+                                            scanSettingsLowPower, mScanCallback);
+                                }
+                            });
+                } finally {
+                    scanner.stopScan(mScanCallback);
+                }
+            }
+            Log.d(TAG, String.format("Scanning done in %d ms. Found device %s.",
+                    System.currentTimeMillis() - startTimeMillis, device));
+
+            try {
+                return connect(device);
+            } catch (BluetoothException e) {
+                retries--;
+                if (retries == 0) {
+                    throw e;
+                } else {
+                    Log.d(TAG, String.format(
+                            "Connection failed: %s. Retrying %d more times.", e, retries));
+                }
+            }
+        }
+    }
+
+    /**
+     * Connects to a device using default connection options.
+     */
+    public BluetoothGattConnection connect(BluetoothDevice bluetoothDevice)
+            throws BluetoothException {
+        return connect(bluetoothDevice, ConnectionOptions.builder().build());
+    }
+
+    /**
+     * Connects to a device using specifies connection options.
+     */
+    public BluetoothGattConnection connect(
+            BluetoothDevice bluetoothDevice, ConnectionOptions options) throws BluetoothException {
+        Log.d(TAG, String.format("Connecting to device %s.", bluetoothDevice));
+        long startTimeMillis = System.currentTimeMillis();
+
+        Operation<BluetoothGattConnection> connectOperation =
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, bluetoothDevice) {
+                    private final Object mLock = new Object();
+
+                    @GuardedBy("mLock")
+                    private boolean mIsCanceled = false;
+
+                    @GuardedBy("mLock")
+                    @Nullable(/* null before operation is executed */)
+                    private BluetoothGattWrapper mBluetoothGatt;
+
+                    @Override
+                    public void run() throws BluetoothException {
+                        synchronized (mLock) {
+                            if (mIsCanceled) {
+                                return;
+                            }
+                            BluetoothGattWrapper bluetoothGattWrapper;
+                            Log.d(TAG, "Use LE transport");
+                            bluetoothGattWrapper =
+                                    bluetoothDevice.connectGatt(
+                                            mApplicationContext,
+                                            options.autoConnect(),
+                                            mBluetoothGattCallback,
+                                            android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+                            if (bluetoothGattWrapper == null) {
+                                throw new BluetoothException("connectGatt() returned null.");
+                            }
+
+                            try {
+                                // Set connection priority without waiting for connection callback.
+                                // Per code, btif_gatt_client.c, when priority is set before
+                                // connection, this sets preferred connection parameters that will
+                                // be used during the connection establishment.
+                                Optional<Integer> connectionPriorityOption =
+                                        options.connectionPriority();
+                                if (connectionPriorityOption.isPresent()) {
+                                    // requestConnectionPriority can only be called when
+                                    // BluetoothGatt is connected to the system BluetoothGatt
+                                    // service (see android/bluetooth/BluetoothGatt.java code).
+                                    // However, there is no callback to the app to inform when this
+                                    // is done. requestConnectionPriority will returns false with no
+                                    // side-effect before the service is connected, so we just poll
+                                    // here until true is returned.
+                                    int connectionPriority = connectionPriorityOption.get();
+                                    long startTimeMillis = System.currentTimeMillis();
+                                    while (!bluetoothGattWrapper.requestConnectionPriority(
+                                            connectionPriority)) {
+                                        if (System.currentTimeMillis() - startTimeMillis
+                                                > options.connectionTimeoutMillis()) {
+                                            throw new BluetoothException(
+                                                    String.format(
+                                                            Locale.US,
+                                                            "Failed to set connectionPriority "
+                                                                    + "after %dms.",
+                                                            options.connectionTimeoutMillis()));
+                                        }
+                                        try {
+                                            Thread.sleep(POLL_INTERVAL_MILLIS);
+                                        } catch (InterruptedException e) {
+                                            Thread.currentThread().interrupt();
+                                            throw new BluetoothException(
+                                                    "connect() operation interrupted.");
+                                        }
+                                    }
+                                }
+                            } catch (Exception e) {
+                                // Make sure to clean connection.
+                                bluetoothGattWrapper.disconnect();
+                                bluetoothGattWrapper.close();
+                                throw e;
+                            }
+
+                            BluetoothGattConnection connection = new BluetoothGattConnection(
+                                    bluetoothGattWrapper, mBluetoothOperationExecutor, options);
+                            mConnections.put(bluetoothGattWrapper, connection);
+                            mBluetoothGatt = bluetoothGattWrapper;
+                        }
+                    }
+
+                    @Override
+                    public void cancel() {
+                        // Clean connection if connection times out.
+                        synchronized (mLock) {
+                            if (mIsCanceled) {
+                                return;
+                            }
+                            mIsCanceled = true;
+                            BluetoothGattWrapper bluetoothGattWrapper = mBluetoothGatt;
+                            if (bluetoothGattWrapper == null) {
+                                return;
+                            }
+                            mConnections.remove(bluetoothGattWrapper);
+                            bluetoothGattWrapper.disconnect();
+                            bluetoothGattWrapper.close();
+                        }
+                    }
+                };
+        BluetoothGattConnection result;
+        if (options.autoConnect()) {
+            result = mBluetoothOperationExecutor.executeNonnull(connectOperation);
+        } else {
+            result =
+                    mBluetoothOperationExecutor.executeNonnull(
+                            connectOperation, options.connectionTimeoutMillis());
+        }
+        Log.d(TAG, String.format("Connection success in %d ms.",
+                System.currentTimeMillis() - startTimeMillis));
+        return result;
+    }
+
+    private BluetoothGattConnection getConnectionByGatt(BluetoothGattWrapper gatt)
+            throws BluetoothException {
+        BluetoothGattConnection connection = mConnections.get(gatt);
+        if (connection == null) {
+            throw new BluetoothException("Receive callback on unexpected device: " + gatt);
+        }
+        return connection;
+    }
+
+    private class InternalBluetoothGattCallback extends BluetoothGattCallback {
+
+        @Override
+        public void onConnectionStateChange(BluetoothGattWrapper gatt, int status, int newState) {
+            BluetoothGattConnection connection;
+            BluetoothDevice device = gatt.getDevice();
+            switch (newState) {
+                case BluetoothGatt.STATE_CONNECTED: {
+                    connection = mConnections.get(gatt);
+                    if (connection == null) {
+                        Log.w(TAG, String.format(
+                                "Received unexpected successful connection for dev %s! Ignoring.",
+                                device));
+                        break;
+                    }
+
+                    Operation<BluetoothGattConnection> operation =
+                            new Operation<>(OperationType.CONNECT, device);
+                    if (status != BluetoothGatt.GATT_SUCCESS) {
+                        mConnections.remove(gatt);
+                        gatt.disconnect();
+                        gatt.close();
+                        mBluetoothOperationExecutor.notifyCompletion(operation, status, null);
+                        break;
+                    }
+
+                    // Process connection options
+                    ConnectionOptions options = connection.getConnectionOptions();
+                    Optional<Integer> mtuOption = options.mtu();
+                    if (mtuOption.isPresent()) {
+                        // Requesting MTU and waiting for MTU callback.
+                        boolean success = gatt.requestMtu(mtuOption.get());
+                        if (!success) {
+                            mBluetoothOperationExecutor.notifyFailure(operation,
+                                    new BluetoothException(String.format(Locale.US,
+                                            "Failed to request MTU of %d for dev %s: "
+                                                    + "returned false.",
+                                            mtuOption.get(), device)));
+                            // Make sure to clean connection.
+                            mConnections.remove(gatt);
+                            gatt.disconnect();
+                            gatt.close();
+                        }
+                        break;
+                    }
+
+                    // Connection successful
+                    connection.onConnected();
+                    mBluetoothOperationExecutor.notifyCompletion(operation, status, connection);
+                    break;
+                }
+                case BluetoothGatt.STATE_DISCONNECTED: {
+                    connection = mConnections.remove(gatt);
+                    if (connection == null) {
+                        Log.w(TAG, String.format("Received unexpected disconnection"
+                                + " for device %s! Ignoring.", device));
+                        break;
+                    }
+                    if (!connection.isConnected()) {
+                        // This is a failed connection attempt
+                        if (status == BluetoothGatt.GATT_SUCCESS) {
+                            // This is weird... considering this as a failure
+                            Log.w(TAG, String.format(
+                                    "Received a success for a failed connection "
+                                            + "attempt for device %s! Ignoring.", device));
+                            status = BluetoothGatt.GATT_FAILURE;
+                        }
+                        mBluetoothOperationExecutor
+                                .notifyCompletion(new Operation<BluetoothGattConnection>(
+                                        OperationType.CONNECT, device), status, null);
+                        // Clean Gatt object in every case.
+                        gatt.disconnect();
+                        gatt.close();
+                        break;
+                    }
+                    connection.onClosed();
+                    mBluetoothOperationExecutor.notifyCompletion(
+                            new Operation<>(OperationType.DISCONNECT, device), status);
+                    break;
+                }
+                default:
+                    Log.e(TAG, "Unexpected connection state: " + newState);
+            }
+        }
+
+        @Override
+        public void onMtuChanged(BluetoothGattWrapper gatt, int mtu, int status) {
+            BluetoothGattConnection connection = mConnections.get(gatt);
+            BluetoothDevice device = gatt.getDevice();
+            if (connection == null) {
+                Log.w(TAG, String.format(
+                        "Received unexpected MTU change for device %s! Ignoring.", device));
+                return;
+            }
+            if (connection.isConnected()) {
+                // This is the callback for the deprecated BluetoothGattConnection.requestMtu.
+                mBluetoothOperationExecutor.notifyCompletion(
+                        new Operation<>(OperationType.CHANGE_MTU, gatt), status, mtu);
+            } else {
+                // This is the callback when requesting MTU right after connecting.
+                connection.onConnected();
+                mBluetoothOperationExecutor.notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, device), status, connection);
+                if (status != BluetoothGatt.GATT_SUCCESS) {
+                    Log.w(TAG, String.format(
+                            "%s responds MTU change failed, status %s.", device, status));
+                    // Clean connection if it's failed.
+                    mConnections.remove(gatt);
+                    gatt.disconnect();
+                    gatt.close();
+                    return;
+                }
+            }
+        }
+
+        @Override
+        public void onServicesDiscovered(BluetoothGattWrapper gatt, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Void>(OperationType.DISCOVER_SERVICES_INTERNAL, gatt), status);
+        }
+
+        @Override
+        public void onCharacteristicRead(BluetoothGattWrapper gatt,
+                BluetoothGattCharacteristic characteristic, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<byte[]>(OperationType.READ_CHARACTERISTIC, gatt, characteristic),
+                    status, characteristic.getValue());
+        }
+
+        @Override
+        public void onCharacteristicWrite(BluetoothGattWrapper gatt,
+                BluetoothGattCharacteristic characteristic, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(new Operation<Void>(
+                    OperationType.WRITE_CHARACTERISTIC, gatt, characteristic), status);
+        }
+
+        @Override
+        public void onDescriptorRead(BluetoothGattWrapper gatt, BluetoothGattDescriptor descriptor,
+                int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<byte[]>(OperationType.READ_DESCRIPTOR, gatt, descriptor), status,
+                    descriptor.getValue());
+        }
+
+        @Override
+        public void onDescriptorWrite(BluetoothGattWrapper gatt, BluetoothGattDescriptor descriptor,
+                int status) {
+            Log.d(TAG, String.format("onDescriptorWrite %s, %s, %d",
+                    gatt.getDevice(), descriptor.getUuid(), status));
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Void>(OperationType.WRITE_DESCRIPTOR, gatt, descriptor), status);
+        }
+
+        @Override
+        public void onReadRemoteRssi(BluetoothGattWrapper gatt, int rssi, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Integer>(OperationType.READ_RSSI, gatt), status, rssi);
+        }
+
+        @Override
+        public void onReliableWriteCompleted(BluetoothGattWrapper gatt, int status) {
+            mBluetoothOperationExecutor.notifyCompletion(
+                    new Operation<Void>(OperationType.WRITE_RELIABLE, gatt), status);
+        }
+
+        @Override
+        public void onCharacteristicChanged(BluetoothGattWrapper gatt,
+                BluetoothGattCharacteristic characteristic) {
+            byte[] value = characteristic.getValue();
+            if (value == null) {
+                // Value is not supposed to be null, but just to be safe...
+                value = new byte[0];
+            }
+            Log.d(TAG, String.format("Characteristic %s changed, Gatt device: %s",
+                    characteristic.getUuid(), gatt.getDevice()));
+            try {
+                getConnectionByGatt(gatt).onCharacteristicChanged(characteristic, value);
+            } catch (BluetoothException e) {
+                Log.e(TAG, "Error in onCharacteristicChanged", e);
+            }
+        }
+    }
+
+    private class InternalScanCallback extends ScanCallback {
+
+        @Override
+        public void onScanFailed(int errorCode) {
+            String errorMessage;
+            switch (errorCode) {
+                case ScanCallback.SCAN_FAILED_ALREADY_STARTED:
+                    errorMessage = "SCAN_FAILED_ALREADY_STARTED";
+                    break;
+                case ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED:
+                    errorMessage = "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED";
+                    break;
+                case ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED:
+                    errorMessage = "SCAN_FAILED_FEATURE_UNSUPPORTED";
+                    break;
+                case ScanCallback.SCAN_FAILED_INTERNAL_ERROR:
+                    errorMessage = "SCAN_FAILED_INTERNAL_ERROR";
+                    break;
+                default:
+                    errorMessage = "Unknown error code - " + errorCode;
+            }
+            mBluetoothOperationExecutor.notifyFailure(
+                    new Operation<BluetoothDevice>(OperationType.SCAN),
+                    new BluetoothException("Scan failed: " + errorMessage));
+        }
+
+        @Override
+        public void onScanResult(int callbackType, ScanResult result) {
+            mBluetoothOperationExecutor.notifySuccess(
+                    new Operation<BluetoothDevice>(OperationType.SCAN), result.getDevice());
+        }
+    }
+
+    /**
+     * Options for {@link #connect}.
+     */
+    public static class ConnectionOptions {
+
+        private boolean mAutoConnect;
+        private long mConnectionTimeoutMillis;
+        private Optional<Integer> mConnectionPriority;
+        private Optional<Integer> mMtu;
+
+        private ConnectionOptions(boolean autoConnect, long connectionTimeoutMillis,
+                Optional<Integer> connectionPriority,
+                Optional<Integer> mtu) {
+            this.mAutoConnect = autoConnect;
+            this.mConnectionTimeoutMillis = connectionTimeoutMillis;
+            this.mConnectionPriority = connectionPriority;
+            this.mMtu = mtu;
+        }
+
+        boolean autoConnect() {
+            return mAutoConnect;
+        }
+
+        long connectionTimeoutMillis() {
+            return mConnectionTimeoutMillis;
+        }
+
+        Optional<Integer> connectionPriority() {
+            return mConnectionPriority;
+        }
+
+        Optional<Integer> mtu() {
+            return mMtu;
+        }
+
+        @Override
+        public String toString() {
+            return "ConnectionOptions{"
+                    + "autoConnect=" + mAutoConnect + ", "
+                    + "connectionTimeoutMillis=" + mConnectionTimeoutMillis + ", "
+                    + "connectionPriority=" + mConnectionPriority + ", "
+                    + "mtu=" + mMtu
+                    + "}";
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o instanceof ConnectionOptions) {
+                ConnectionOptions that = (ConnectionOptions) o;
+                return this.mAutoConnect == that.autoConnect()
+                        && this.mConnectionTimeoutMillis == that.connectionTimeoutMillis()
+                        && this.mConnectionPriority.equals(that.connectionPriority())
+                        && this.mMtu.equals(that.mtu());
+            }
+            return false;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mAutoConnect, mConnectionTimeoutMillis, mConnectionPriority, mMtu);
+        }
+
+        /**
+         * Creates a builder of ConnectionOptions.
+         */
+        public static Builder builder() {
+            return new ConnectionOptions.Builder()
+                    .setAutoConnect(false)
+                    .setConnectionTimeoutMillis(TimeUnit.SECONDS.toMillis(5));
+        }
+
+        /**
+         * Builder for {@link ConnectionOptions}.
+         */
+        public static class Builder {
+
+            private boolean mAutoConnect;
+            private long mConnectionTimeoutMillis;
+            private Optional<Integer> mConnectionPriority = Optional.empty();
+            private Optional<Integer> mMtu = Optional.empty();
+
+            /**
+             * See {@link android.bluetooth.BluetoothDevice#connectGatt}.
+             */
+            public Builder setAutoConnect(boolean autoConnect) {
+                this.mAutoConnect = autoConnect;
+                return this;
+            }
+
+            /**
+             * See {@link android.bluetooth.BluetoothGatt#requestConnectionPriority(int)}.
+             */
+            public Builder setConnectionPriority(int connectionPriority) {
+                this.mConnectionPriority = Optional.of(connectionPriority);
+                return this;
+            }
+
+            /**
+             * See {@link android.bluetooth.BluetoothGatt#requestMtu(int)}.
+             */
+            public Builder setMtu(int mtu) {
+                this.mMtu = Optional.of(mtu);
+                return this;
+            }
+
+            /**
+             * Sets the timeout for the GATT connection.
+             */
+            public Builder setConnectionTimeoutMillis(long connectionTimeoutMillis) {
+                this.mConnectionTimeoutMillis = connectionTimeoutMillis;
+                return this;
+            }
+
+            /**
+             * Builds ConnectionOptions.
+             */
+            public ConnectionOptions build() {
+                return new ConnectionOptions(mAutoConnect, mConnectionTimeoutMillis,
+                        mConnectionPriority, mMtu);
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/NonnullProvider.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/NonnullProvider.java
new file mode 100644
index 0000000..16abd99
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/NonnullProvider.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 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.common.bluetooth.testability;
+
+/**
+ * Provider that returns non-null instances.
+ *
+ * @param <T> Type of provided instance.
+ */
+public interface NonnullProvider<T> {
+    /** Get a non-null instance. */
+    T get();
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/Testability.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/Testability.java
new file mode 100644
index 0000000..6cfdd78
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/Testability.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 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.common.bluetooth.testability;
+
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+
+import javax.annotation.Nullable;
+
+/** Util class to convert from or to testable classes. */
+public class Testability {
+    /** Wraps a Bluetooth device. */
+    public static BluetoothDevice wrap(android.bluetooth.BluetoothDevice bluetoothDevice) {
+        return BluetoothDevice.wrap(bluetoothDevice);
+    }
+
+    /** Wraps a Bluetooth adapter. */
+    @Nullable
+    public static BluetoothAdapter wrap(
+            @Nullable android.bluetooth.BluetoothAdapter bluetoothAdapter) {
+        return BluetoothAdapter.wrap(bluetoothAdapter);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/TimeProvider.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/TimeProvider.java
new file mode 100644
index 0000000..a4de913
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/TimeProvider.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 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.common.bluetooth.testability;
+
+/** Provider of time for testability. */
+public class TimeProvider {
+    public long getTimeMillis() {
+        return System.currentTimeMillis();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/VersionProvider.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/VersionProvider.java
new file mode 100644
index 0000000..f46ea7a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/VersionProvider.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 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.common.bluetooth.testability;
+
+import android.os.Build.VERSION;
+
+/**
+ * Provider of android sdk version for testability
+ */
+public class VersionProvider {
+    public int getSdkInt() {
+        return VERSION.SDK_INT;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothAdapter.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothAdapter.java
new file mode 100644
index 0000000..afa2a1b
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothAdapter.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.BluetoothLeAdvertiser;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.BluetoothLeScanner;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.annotation.Nullable;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.BluetoothAdapter}.
+ */
+public class BluetoothAdapter {
+    /** See {@link android.bluetooth.BluetoothAdapter#ACTION_REQUEST_ENABLE}. */
+    public static final String ACTION_REQUEST_ENABLE =
+            android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE;
+
+    /** See {@link android.bluetooth.BluetoothAdapter#ACTION_STATE_CHANGED}. */
+    public static final String ACTION_STATE_CHANGED =
+            android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED;
+
+    /** See {@link android.bluetooth.BluetoothAdapter#EXTRA_STATE}. */
+    public static final String EXTRA_STATE =
+            android.bluetooth.BluetoothAdapter.EXTRA_STATE;
+
+    /** See {@link android.bluetooth.BluetoothAdapter#STATE_OFF}. */
+    public static final int STATE_OFF =
+            android.bluetooth.BluetoothAdapter.STATE_OFF;
+
+    /** See {@link android.bluetooth.BluetoothAdapter#STATE_ON}. */
+    public static final int STATE_ON =
+            android.bluetooth.BluetoothAdapter.STATE_ON;
+
+    /** See {@link android.bluetooth.BluetoothAdapter#STATE_TURNING_OFF}. */
+    public static final int STATE_TURNING_OFF =
+            android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF;
+
+    /** See {@link android.bluetooth.BluetoothAdapter#STATE_TURNING_ON}. */
+    public static final int STATE_TURNING_ON =
+            android.bluetooth.BluetoothAdapter.STATE_TURNING_ON;
+
+    private final android.bluetooth.BluetoothAdapter mWrappedBluetoothAdapter;
+
+    private BluetoothAdapter(android.bluetooth.BluetoothAdapter bluetoothAdapter) {
+        mWrappedBluetoothAdapter = bluetoothAdapter;
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#disable()}. */
+    public boolean disable() {
+        return mWrappedBluetoothAdapter.disable();
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#enable()}. */
+    public boolean enable() {
+        return mWrappedBluetoothAdapter.enable();
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#getBluetoothLeScanner}. */
+    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+    @Nullable
+    public BluetoothLeScanner getBluetoothLeScanner() {
+        return BluetoothLeScanner.wrap(mWrappedBluetoothAdapter.getBluetoothLeScanner());
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#getBluetoothLeAdvertiser()}. */
+    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+    @Nullable
+    public BluetoothLeAdvertiser getBluetoothLeAdvertiser() {
+        return BluetoothLeAdvertiser.wrap(mWrappedBluetoothAdapter.getBluetoothLeAdvertiser());
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#getBondedDevices()}. */
+    @Nullable
+    public Set<BluetoothDevice> getBondedDevices() {
+        Set<android.bluetooth.BluetoothDevice> bondedDevices =
+                mWrappedBluetoothAdapter.getBondedDevices();
+        if (bondedDevices == null) {
+            return null;
+        }
+        Set<BluetoothDevice> result = new HashSet<BluetoothDevice>();
+        for (android.bluetooth.BluetoothDevice device : bondedDevices) {
+            if (device == null) {
+                continue;
+            }
+            result.add(BluetoothDevice.wrap(device));
+        }
+        return Collections.unmodifiableSet(result);
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#getRemoteDevice(byte[])}. */
+    public BluetoothDevice getRemoteDevice(byte[] address) {
+        return BluetoothDevice.wrap(mWrappedBluetoothAdapter.getRemoteDevice(address));
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#getRemoteDevice(String)}. */
+    public BluetoothDevice getRemoteDevice(String address) {
+        return BluetoothDevice.wrap(mWrappedBluetoothAdapter.getRemoteDevice(address));
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#isEnabled()}. */
+    public boolean isEnabled() {
+        return mWrappedBluetoothAdapter.isEnabled();
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#isDiscovering()}. */
+    public boolean isDiscovering() {
+        return mWrappedBluetoothAdapter.isDiscovering();
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#startDiscovery()}. */
+    public boolean startDiscovery() {
+        return mWrappedBluetoothAdapter.startDiscovery();
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#cancelDiscovery()}. */
+    public boolean cancelDiscovery() {
+        return mWrappedBluetoothAdapter.cancelDiscovery();
+    }
+
+    /** See {@link android.bluetooth.BluetoothAdapter#getDefaultAdapter()}. */
+    @Nullable
+    public static BluetoothAdapter getDefaultAdapter() {
+        android.bluetooth.BluetoothAdapter adapter =
+                android.bluetooth.BluetoothAdapter.getDefaultAdapter();
+        if (adapter == null) {
+            return null;
+        }
+        return new BluetoothAdapter(adapter);
+    }
+
+    /** Wraps a Bluetooth adapter. */
+    @Nullable
+    public static BluetoothAdapter wrap(
+            @Nullable android.bluetooth.BluetoothAdapter bluetoothAdapter) {
+        if (bluetoothAdapter == null) {
+            return null;
+        }
+        return new BluetoothAdapter(bluetoothAdapter);
+    }
+
+    /** Unwraps a Bluetooth adapter. */
+    public android.bluetooth.BluetoothAdapter unwrap() {
+        return mWrappedBluetoothAdapter;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothDevice.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothDevice.java
new file mode 100644
index 0000000..5b45f61
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothDevice.java
@@ -0,0 +1,277 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothClass;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.os.ParcelUuid;
+
+import java.io.IOException;
+import java.util.UUID;
+
+import javax.annotation.Nullable;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.BluetoothDevice}.
+ */
+@TargetApi(18)
+public class BluetoothDevice {
+    /** See {@link android.bluetooth.BluetoothDevice#BOND_BONDED}. */
+    public static final int BOND_BONDED = android.bluetooth.BluetoothDevice.BOND_BONDED;
+
+    /** See {@link android.bluetooth.BluetoothDevice#BOND_BONDING}. */
+    public static final int BOND_BONDING = android.bluetooth.BluetoothDevice.BOND_BONDING;
+
+    /** See {@link android.bluetooth.BluetoothDevice#BOND_NONE}. */
+    public static final int BOND_NONE = android.bluetooth.BluetoothDevice.BOND_NONE;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_ACL_CONNECTED}. */
+    public static final String ACTION_ACL_CONNECTED =
+            android.bluetooth.BluetoothDevice.ACTION_ACL_CONNECTED;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_ACL_DISCONNECT_REQUESTED}. */
+    public static final String ACTION_ACL_DISCONNECT_REQUESTED =
+            android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_ACL_DISCONNECTED}. */
+    public static final String ACTION_ACL_DISCONNECTED =
+            android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECTED;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_BOND_STATE_CHANGED}. */
+    public static final String ACTION_BOND_STATE_CHANGED =
+            android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_CLASS_CHANGED}. */
+    public static final String ACTION_CLASS_CHANGED =
+            android.bluetooth.BluetoothDevice.ACTION_CLASS_CHANGED;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_FOUND}. */
+    public static final String ACTION_FOUND = android.bluetooth.BluetoothDevice.ACTION_FOUND;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_NAME_CHANGED}. */
+    public static final String ACTION_NAME_CHANGED =
+            android.bluetooth.BluetoothDevice.ACTION_NAME_CHANGED;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_PAIRING_REQUEST}. */
+    // API 19 only
+    public static final String ACTION_PAIRING_REQUEST =
+            "android.bluetooth.device.action.PAIRING_REQUEST";
+
+    /** See {@link android.bluetooth.BluetoothDevice#ACTION_UUID}. */
+    public static final String ACTION_UUID = android.bluetooth.BluetoothDevice.ACTION_UUID;
+
+    /** See {@link android.bluetooth.BluetoothDevice#DEVICE_TYPE_CLASSIC}. */
+    public static final int DEVICE_TYPE_CLASSIC =
+            android.bluetooth.BluetoothDevice.DEVICE_TYPE_CLASSIC;
+
+    /** See {@link android.bluetooth.BluetoothDevice#DEVICE_TYPE_DUAL}. */
+    public static final int DEVICE_TYPE_DUAL = android.bluetooth.BluetoothDevice.DEVICE_TYPE_DUAL;
+
+    /** See {@link android.bluetooth.BluetoothDevice#DEVICE_TYPE_LE}. */
+    public static final int DEVICE_TYPE_LE = android.bluetooth.BluetoothDevice.DEVICE_TYPE_LE;
+
+    /** See {@link android.bluetooth.BluetoothDevice#DEVICE_TYPE_UNKNOWN}. */
+    public static final int DEVICE_TYPE_UNKNOWN =
+            android.bluetooth.BluetoothDevice.DEVICE_TYPE_UNKNOWN;
+
+    /** See {@link android.bluetooth.BluetoothDevice#ERROR}. */
+    public static final int ERROR = android.bluetooth.BluetoothDevice.ERROR;
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_BOND_STATE}. */
+    public static final String EXTRA_BOND_STATE =
+            android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE;
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_CLASS}. */
+    public static final String EXTRA_CLASS = android.bluetooth.BluetoothDevice.EXTRA_CLASS;
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_DEVICE}. */
+    public static final String EXTRA_DEVICE = android.bluetooth.BluetoothDevice.EXTRA_DEVICE;
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_NAME}. */
+    public static final String EXTRA_NAME = android.bluetooth.BluetoothDevice.EXTRA_NAME;
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_PAIRING_KEY}. */
+    // API 19 only
+    public static final String EXTRA_PAIRING_KEY = "android.bluetooth.device.extra.PAIRING_KEY";
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_PAIRING_VARIANT}. */
+    // API 19 only
+    public static final String EXTRA_PAIRING_VARIANT =
+            "android.bluetooth.device.extra.PAIRING_VARIANT";
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_PREVIOUS_BOND_STATE}. */
+    public static final String EXTRA_PREVIOUS_BOND_STATE =
+            android.bluetooth.BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE;
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_RSSI}. */
+    public static final String EXTRA_RSSI = android.bluetooth.BluetoothDevice.EXTRA_RSSI;
+
+    /** See {@link android.bluetooth.BluetoothDevice#EXTRA_UUID}. */
+    public static final String EXTRA_UUID = android.bluetooth.BluetoothDevice.EXTRA_UUID;
+
+    /** See {@link android.bluetooth.BluetoothDevice#PAIRING_VARIANT_PASSKEY_CONFIRMATION}. */
+    // API 19 only
+    public static final int PAIRING_VARIANT_PASSKEY_CONFIRMATION = 2;
+
+    /** See {@link android.bluetooth.BluetoothDevice#PAIRING_VARIANT_PIN}. */
+    // API 19 only
+    public static final int PAIRING_VARIANT_PIN = 0;
+
+    private final android.bluetooth.BluetoothDevice mWrappedBluetoothDevice;
+
+    private BluetoothDevice(android.bluetooth.BluetoothDevice bluetoothDevice) {
+        mWrappedBluetoothDevice = bluetoothDevice;
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothDevice#connectGatt(Context, boolean,
+     * android.bluetooth.BluetoothGattCallback)}.
+     */
+    @Nullable(/* when bt service is not available */)
+    public BluetoothGattWrapper connectGatt(Context context, boolean autoConnect,
+            BluetoothGattCallback callback) {
+        android.bluetooth.BluetoothGatt gatt =
+                mWrappedBluetoothDevice.connectGatt(context, autoConnect, callback.unwrap());
+        if (gatt == null) {
+            return null;
+        }
+        return BluetoothGattWrapper.wrap(gatt);
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothDevice#connectGatt(Context, boolean,
+     * android.bluetooth.BluetoothGattCallback, int)}.
+     */
+    @TargetApi(23)
+    @Nullable(/* when bt service is not available */)
+    public BluetoothGattWrapper connectGatt(Context context, boolean autoConnect,
+            BluetoothGattCallback callback, int transport) {
+        android.bluetooth.BluetoothGatt gatt =
+                mWrappedBluetoothDevice.connectGatt(
+                        context, autoConnect, callback.unwrap(), transport);
+        if (gatt == null) {
+            return null;
+        }
+        return BluetoothGattWrapper.wrap(gatt);
+    }
+
+
+    /**
+     * See {@link android.bluetooth.BluetoothDevice#createRfcommSocketToServiceRecord(UUID)}.
+     */
+    public BluetoothSocket createRfcommSocketToServiceRecord(UUID uuid) throws IOException {
+        return mWrappedBluetoothDevice.createRfcommSocketToServiceRecord(uuid);
+    }
+
+    /**
+     * See
+     * {@link android.bluetooth.BluetoothDevice#createInsecureRfcommSocketToServiceRecord(UUID)}.
+     */
+    public BluetoothSocket createInsecureRfcommSocketToServiceRecord(UUID uuid) throws IOException {
+        return mWrappedBluetoothDevice.createInsecureRfcommSocketToServiceRecord(uuid);
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#setPin(byte[])}. */
+    @TargetApi(19)
+    public boolean setPairingConfirmation(byte[] pin) {
+        return mWrappedBluetoothDevice.setPin(pin);
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#setPairingConfirmation(boolean)}. */
+    public boolean setPairingConfirmation(boolean confirm) {
+        return mWrappedBluetoothDevice.setPairingConfirmation(confirm);
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#fetchUuidsWithSdp()}. */
+    public boolean fetchUuidsWithSdp() {
+        return mWrappedBluetoothDevice.fetchUuidsWithSdp();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#createBond()}. */
+    public boolean createBond() {
+        return mWrappedBluetoothDevice.createBond();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#getUuids()}. */
+    @Nullable(/* on error */)
+    public ParcelUuid[] getUuids() {
+        return mWrappedBluetoothDevice.getUuids();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#getBondState()}. */
+    public int getBondState() {
+        return mWrappedBluetoothDevice.getBondState();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#getAddress()}. */
+    public String getAddress() {
+        return mWrappedBluetoothDevice.getAddress();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#getBluetoothClass()}. */
+    @Nullable(/* on error */)
+    public BluetoothClass getBluetoothClass() {
+        return mWrappedBluetoothDevice.getBluetoothClass();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#getType()}. */
+    public int getType() {
+        return mWrappedBluetoothDevice.getType();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#getName()}. */
+    @Nullable(/* on error */)
+    public String getName() {
+        return mWrappedBluetoothDevice.getName();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#toString()}. */
+    @Override
+    public String toString() {
+        return mWrappedBluetoothDevice.toString();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#hashCode()}. */
+    @Override
+    public int hashCode() {
+        return mWrappedBluetoothDevice.hashCode();
+    }
+
+    /** See {@link android.bluetooth.BluetoothDevice#equals(Object)}. */
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o ==  this) {
+            return true;
+        }
+        if (!(o instanceof BluetoothDevice)) {
+            return false;
+        }
+        return mWrappedBluetoothDevice.equals(((BluetoothDevice) o).unwrap());
+    }
+
+    /** Unwraps a Bluetooth device. */
+    public android.bluetooth.BluetoothDevice unwrap() {
+        return mWrappedBluetoothDevice;
+    }
+
+    /** Wraps a Bluetooth device. */
+    public static BluetoothDevice wrap(android.bluetooth.BluetoothDevice bluetoothDevice) {
+        return new BluetoothDevice(bluetoothDevice);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattCallback.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattCallback.java
new file mode 100644
index 0000000..d36cfa2
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattCallback.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+
+/**
+ * Wrapper of {@link android.bluetooth.BluetoothGattCallback} that uses mockable objects.
+ */
+public abstract class BluetoothGattCallback {
+
+    private final android.bluetooth.BluetoothGattCallback mWrappedBluetoothGattCallback =
+            new InternalBluetoothGattCallback();
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onConnectionStateChange(
+     * android.bluetooth.BluetoothGatt, int, int)}
+     */
+    public void onConnectionStateChange(BluetoothGattWrapper gatt, int status, int newState) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onServicesDiscovered(
+     * android.bluetooth.BluetoothGatt,int)}
+     */
+    public void onServicesDiscovered(BluetoothGattWrapper gatt, int status) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onCharacteristicRead(
+     * android.bluetooth.BluetoothGatt, BluetoothGattCharacteristic, int)}
+     */
+    public void onCharacteristicRead(BluetoothGattWrapper gatt, BluetoothGattCharacteristic
+            characteristic, int status) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onCharacteristicWrite(
+     * android.bluetooth.BluetoothGatt, BluetoothGattCharacteristic, int)}
+     */
+    public void onCharacteristicWrite(BluetoothGattWrapper gatt,
+            BluetoothGattCharacteristic characteristic, int status) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onDescriptorRead(
+     * android.bluetooth.BluetoothGatt, BluetoothGattDescriptor, int)}
+     */
+    public void onDescriptorRead(
+            BluetoothGattWrapper gatt, BluetoothGattDescriptor descriptor, int status) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onDescriptorWrite(
+     * android.bluetooth.BluetoothGatt, BluetoothGattDescriptor, int)}
+     */
+    public void onDescriptorWrite(BluetoothGattWrapper gatt, BluetoothGattDescriptor descriptor,
+            int status) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onReadRemoteRssi(
+     * android.bluetooth.BluetoothGatt, int, int)}
+     */
+    public void onReadRemoteRssi(BluetoothGattWrapper gatt, int rssi, int status) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattCallback#onReliableWriteCompleted(
+     * android.bluetooth.BluetoothGatt, int)}
+     */
+    public void onReliableWriteCompleted(BluetoothGattWrapper gatt, int status) {}
+
+    /**
+     * See
+     * {@link android.bluetooth.BluetoothGattCallback#onMtuChanged(android.bluetooth.BluetoothGatt,
+     * int, int)}
+     */
+    public void onMtuChanged(BluetoothGattWrapper gatt, int mtu, int status) {}
+
+    /**
+     * See
+     * {@link android.bluetooth.BluetoothGattCallback#onCharacteristicChanged(
+     * android.bluetooth.BluetoothGatt, BluetoothGattCharacteristic)}
+     */
+    public void onCharacteristicChanged(BluetoothGattWrapper gatt,
+            BluetoothGattCharacteristic characteristic) {}
+
+    /** Unwraps a Bluetooth Gatt callback. */
+    public android.bluetooth.BluetoothGattCallback unwrap() {
+        return mWrappedBluetoothGattCallback;
+    }
+
+    /** Forward callback to testable instance. */
+    private class InternalBluetoothGattCallback extends android.bluetooth.BluetoothGattCallback {
+        @Override
+        public void onConnectionStateChange(android.bluetooth.BluetoothGatt gatt, int status,
+                int newState) {
+            BluetoothGattCallback.this.onConnectionStateChange(BluetoothGattWrapper.wrap(gatt),
+                    status, newState);
+        }
+
+        @Override
+        public void onServicesDiscovered(android.bluetooth.BluetoothGatt gatt, int status) {
+            BluetoothGattCallback.this.onServicesDiscovered(BluetoothGattWrapper.wrap(gatt),
+                    status);
+        }
+
+        @Override
+        public void onCharacteristicRead(android.bluetooth.BluetoothGatt gatt,
+                BluetoothGattCharacteristic characteristic, int status) {
+            BluetoothGattCallback.this.onCharacteristicRead(
+                    BluetoothGattWrapper.wrap(gatt), characteristic, status);
+        }
+
+        @Override
+        public void onCharacteristicWrite(android.bluetooth.BluetoothGatt gatt,
+                BluetoothGattCharacteristic characteristic, int status) {
+            BluetoothGattCallback.this.onCharacteristicWrite(
+                    BluetoothGattWrapper.wrap(gatt), characteristic, status);
+        }
+
+        @Override
+        public void onDescriptorRead(android.bluetooth.BluetoothGatt gatt,
+                BluetoothGattDescriptor descriptor, int status) {
+            BluetoothGattCallback.this.onDescriptorRead(
+                    BluetoothGattWrapper.wrap(gatt), descriptor, status);
+        }
+
+        @Override
+        public void onDescriptorWrite(android.bluetooth.BluetoothGatt gatt,
+                BluetoothGattDescriptor descriptor, int status) {
+            BluetoothGattCallback.this.onDescriptorWrite(
+                    BluetoothGattWrapper.wrap(gatt), descriptor, status);
+        }
+
+        @Override
+        public void onReadRemoteRssi(android.bluetooth.BluetoothGatt gatt, int rssi, int status) {
+            BluetoothGattCallback.this.onReadRemoteRssi(BluetoothGattWrapper.wrap(gatt), rssi,
+                    status);
+        }
+
+        @Override
+        public void onReliableWriteCompleted(android.bluetooth.BluetoothGatt gatt, int status) {
+            BluetoothGattCallback.this.onReliableWriteCompleted(BluetoothGattWrapper.wrap(gatt),
+                    status);
+        }
+
+        @Override
+        public void onMtuChanged(android.bluetooth.BluetoothGatt gatt, int mtu, int status) {
+            BluetoothGattCallback.this.onMtuChanged(BluetoothGattWrapper.wrap(gatt), mtu, status);
+        }
+
+        @Override
+        public void onCharacteristicChanged(android.bluetooth.BluetoothGatt gatt,
+                BluetoothGattCharacteristic characteristic) {
+            BluetoothGattCallback.this.onCharacteristicChanged(
+                    BluetoothGattWrapper.wrap(gatt), characteristic);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattServer.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattServer.java
new file mode 100644
index 0000000..3f6f361
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattServer.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattService;
+
+import java.util.UUID;
+
+import javax.annotation.Nullable;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.BluetoothGattServer}.
+ */
+public class BluetoothGattServer {
+
+    /** See {@link android.bluetooth.BluetoothGattServer#STATE_CONNECTED}. */
+    public static final int STATE_CONNECTED = android.bluetooth.BluetoothGattServer.STATE_CONNECTED;
+
+    /** See {@link android.bluetooth.BluetoothGattServer#STATE_DISCONNECTED}. */
+    public static final int STATE_DISCONNECTED =
+            android.bluetooth.BluetoothGattServer.STATE_DISCONNECTED;
+
+    private android.bluetooth.BluetoothGattServer mWrappedInstance;
+
+    private BluetoothGattServer(android.bluetooth.BluetoothGattServer instance) {
+        mWrappedInstance = instance;
+    }
+
+    /** Wraps a Bluetooth Gatt server. */
+    @Nullable
+    public static BluetoothGattServer wrap(
+            @Nullable android.bluetooth.BluetoothGattServer instance) {
+        if (instance == null) {
+            return null;
+        }
+        return new BluetoothGattServer(instance);
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServer#connect(
+     * android.bluetooth.BluetoothDevice, boolean)}
+     */
+    public boolean connect(BluetoothDevice device, boolean autoConnect) {
+        return mWrappedInstance.connect(device.unwrap(), autoConnect);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGattServer#addService(BluetoothGattService)}. */
+    public boolean addService(BluetoothGattService service) {
+        return mWrappedInstance.addService(service);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGattServer#clearServices()}. */
+    public void clearServices() {
+        mWrappedInstance.clearServices();
+    }
+
+    /** See {@link android.bluetooth.BluetoothGattServer#close()}. */
+    public void close() {
+        mWrappedInstance.close();
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServer#notifyCharacteristicChanged(
+     * android.bluetooth.BluetoothDevice, BluetoothGattCharacteristic, boolean)}.
+     */
+    public boolean notifyCharacteristicChanged(BluetoothDevice device,
+            BluetoothGattCharacteristic characteristic, boolean confirm) {
+        return mWrappedInstance.notifyCharacteristicChanged(
+                device.unwrap(), characteristic, confirm);
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServer#sendResponse(
+     * android.bluetooth.BluetoothDevice, int, int, int, byte[])}.
+     */
+    public void sendResponse(BluetoothDevice device, int requestId, int status, int offset,
+            @Nullable byte[] value) {
+        mWrappedInstance.sendResponse(device.unwrap(), requestId, status, offset, value);
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServer#cancelConnection(
+     * android.bluetooth.BluetoothDevice)}.
+     */
+    public void cancelConnection(BluetoothDevice device) {
+        mWrappedInstance.cancelConnection(device.unwrap());
+    }
+
+    /** See {@link android.bluetooth.BluetoothGattServer#getService(UUID uuid)}. */
+    public BluetoothGattService getService(UUID uuid) {
+        return mWrappedInstance.getService(uuid);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattServerCallback.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattServerCallback.java
new file mode 100644
index 0000000..875dad5
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattServerCallback.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+
+/**
+ * Wrapper of {@link android.bluetooth.BluetoothGattServerCallback} that uses mockable objects.
+ */
+public abstract class BluetoothGattServerCallback {
+
+    private final android.bluetooth.BluetoothGattServerCallback mWrappedInstance =
+            new InternalBluetoothGattServerCallback();
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onCharacteristicReadRequest(
+     * android.bluetooth.BluetoothDevice, int, int, BluetoothGattCharacteristic)}
+     */
+    public void onCharacteristicReadRequest(BluetoothDevice device, int requestId,
+            int offset, BluetoothGattCharacteristic characteristic) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onCharacteristicWriteRequest(
+     * android.bluetooth.BluetoothDevice, int, BluetoothGattCharacteristic, boolean, boolean, int,
+     * byte[])}
+     */
+    public void onCharacteristicWriteRequest(BluetoothDevice device,
+            int requestId,
+            BluetoothGattCharacteristic characteristic,
+            boolean preparedWrite,
+            boolean responseNeeded,
+            int offset,
+            byte[] value) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onConnectionStateChange(
+     * android.bluetooth.BluetoothDevice, int, int)}
+     */
+    public void onConnectionStateChange(BluetoothDevice device, int status, int newState) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onDescriptorReadRequest(
+     * android.bluetooth.BluetoothDevice, int, int, BluetoothGattDescriptor)}
+     */
+    public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset,
+            BluetoothGattDescriptor descriptor) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onDescriptorWriteRequest(
+     * android.bluetooth.BluetoothDevice, int, BluetoothGattDescriptor, boolean, boolean, int,
+     * byte[])}
+     */
+    public void onDescriptorWriteRequest(BluetoothDevice device,
+            int requestId,
+            BluetoothGattDescriptor descriptor,
+            boolean preparedWrite,
+            boolean responseNeeded,
+            int offset,
+            byte[] value) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onExecuteWrite(
+     * android.bluetooth.BluetoothDevice, int, boolean)}
+     */
+    public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onMtuChanged(
+     * android.bluetooth.BluetoothDevice, int)}
+     */
+    public void onMtuChanged(BluetoothDevice device, int mtu) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onNotificationSent(
+     * android.bluetooth.BluetoothDevice, int)}
+     */
+    public void onNotificationSent(BluetoothDevice device, int status) {}
+
+    /**
+     * See {@link android.bluetooth.BluetoothGattServerCallback#onServiceAdded(int,
+     * BluetoothGattService)}
+     */
+    public void onServiceAdded(int status, BluetoothGattService service) {}
+
+    /** Unwraps a Bluetooth Gatt server callback. */
+    public android.bluetooth.BluetoothGattServerCallback unwrap() {
+        return mWrappedInstance;
+    }
+
+    /** Forward callback to testable instance. */
+    private class InternalBluetoothGattServerCallback extends
+            android.bluetooth.BluetoothGattServerCallback {
+        @Override
+        public void onCharacteristicReadRequest(android.bluetooth.BluetoothDevice device,
+                int requestId, int offset, BluetoothGattCharacteristic characteristic) {
+            BluetoothGattServerCallback.this.onCharacteristicReadRequest(
+                    BluetoothDevice.wrap(device), requestId, offset, characteristic);
+        }
+
+        @Override
+        public void onCharacteristicWriteRequest(android.bluetooth.BluetoothDevice device,
+                int requestId,
+                BluetoothGattCharacteristic characteristic,
+                boolean preparedWrite,
+                boolean responseNeeded,
+                int offset,
+                byte[] value) {
+            BluetoothGattServerCallback.this.onCharacteristicWriteRequest(
+                    BluetoothDevice.wrap(device),
+                    requestId,
+                    characteristic,
+                    preparedWrite,
+                    responseNeeded,
+                    offset,
+                    value);
+        }
+
+        @Override
+        public void onConnectionStateChange(android.bluetooth.BluetoothDevice device, int status,
+                int newState) {
+            BluetoothGattServerCallback.this.onConnectionStateChange(
+                    BluetoothDevice.wrap(device), status, newState);
+        }
+
+        @Override
+        public void onDescriptorReadRequest(android.bluetooth.BluetoothDevice device, int requestId,
+                int offset, BluetoothGattDescriptor descriptor) {
+            BluetoothGattServerCallback.this.onDescriptorReadRequest(BluetoothDevice.wrap(device),
+                    requestId, offset, descriptor);
+        }
+
+        @Override
+        public void onDescriptorWriteRequest(android.bluetooth.BluetoothDevice device,
+                int requestId,
+                BluetoothGattDescriptor descriptor,
+                boolean preparedWrite,
+                boolean responseNeeded,
+                int offset,
+                byte[] value) {
+            BluetoothGattServerCallback.this.onDescriptorWriteRequest(BluetoothDevice.wrap(device),
+                    requestId,
+                    descriptor,
+                    preparedWrite,
+                    responseNeeded,
+                    offset,
+                    value);
+        }
+
+        @Override
+        public void onExecuteWrite(android.bluetooth.BluetoothDevice device, int requestId,
+                boolean execute) {
+            BluetoothGattServerCallback.this.onExecuteWrite(BluetoothDevice.wrap(device), requestId,
+                    execute);
+        }
+
+        @Override
+        public void onMtuChanged(android.bluetooth.BluetoothDevice device, int mtu) {
+            BluetoothGattServerCallback.this.onMtuChanged(BluetoothDevice.wrap(device), mtu);
+        }
+
+        @Override
+        public void onNotificationSent(android.bluetooth.BluetoothDevice device, int status) {
+            BluetoothGattServerCallback.this.onNotificationSent(
+                    BluetoothDevice.wrap(device), status);
+        }
+
+        @Override
+        public void onServiceAdded(int status, BluetoothGattService service) {
+            BluetoothGattServerCallback.this.onServiceAdded(status, service);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattWrapper.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattWrapper.java
new file mode 100644
index 0000000..453ee5d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothGattWrapper.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.os.Build;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.UUID;
+
+import javax.annotation.Nullable;
+
+/** Mockable wrapper of {@link android.bluetooth.BluetoothGatt}. */
+@TargetApi(Build.VERSION_CODES.TIRAMISU)
+public class BluetoothGattWrapper {
+    private final android.bluetooth.BluetoothGatt mWrappedBluetoothGatt;
+
+    private BluetoothGattWrapper(android.bluetooth.BluetoothGatt bluetoothGatt) {
+        mWrappedBluetoothGatt = bluetoothGatt;
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#getDevice()}. */
+    public BluetoothDevice getDevice() {
+        return BluetoothDevice.wrap(mWrappedBluetoothGatt.getDevice());
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#getServices()}. */
+    public List<BluetoothGattService> getServices() {
+        return mWrappedBluetoothGatt.getServices();
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#getService(UUID)}. */
+    @Nullable(/* null if service is not found */)
+    public BluetoothGattService getService(UUID uuid) {
+        return mWrappedBluetoothGatt.getService(uuid);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#discoverServices()}. */
+    public boolean discoverServices() {
+        return mWrappedBluetoothGatt.discoverServices();
+    }
+
+    /**
+     * Hidden method. Clears the internal cache and forces a refresh of the services from the remote
+     * device.
+     */
+    // TODO(b/201300471): remove refresh call using reflection.
+    public boolean refresh() {
+        try {
+            Method refreshMethod = android.bluetooth.BluetoothGatt.class.getMethod("refresh");
+            return (Boolean) refreshMethod.invoke(mWrappedBluetoothGatt);
+        } catch (NoSuchMethodException
+            | IllegalAccessException
+            | IllegalArgumentException
+            | InvocationTargetException e) {
+            return false;
+        }
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothGatt#readCharacteristic(BluetoothGattCharacteristic)}.
+     */
+    public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) {
+        return mWrappedBluetoothGatt.readCharacteristic(characteristic);
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothGatt#writeCharacteristic(BluetoothGattCharacteristic,
+     * byte[], int)} .
+     */
+    public int writeCharacteristic(BluetoothGattCharacteristic characteristic, byte[] value,
+            int writeType) {
+        return mWrappedBluetoothGatt.writeCharacteristic(characteristic, value, writeType);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#readDescriptor(BluetoothGattDescriptor)}. */
+    public boolean readDescriptor(BluetoothGattDescriptor descriptor) {
+        return mWrappedBluetoothGatt.readDescriptor(descriptor);
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothGatt#writeDescriptor(BluetoothGattDescriptor,
+     * byte[])}.
+     */
+    public int writeDescriptor(BluetoothGattDescriptor descriptor, byte[] value) {
+        return mWrappedBluetoothGatt.writeDescriptor(descriptor, value);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#readRemoteRssi()}. */
+    public boolean readRemoteRssi() {
+        return mWrappedBluetoothGatt.readRemoteRssi();
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#requestConnectionPriority(int)}. */
+    public boolean requestConnectionPriority(int connectionPriority) {
+        return mWrappedBluetoothGatt.requestConnectionPriority(connectionPriority);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#requestMtu(int)}. */
+    public boolean requestMtu(int mtu) {
+        return mWrappedBluetoothGatt.requestMtu(mtu);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#setCharacteristicNotification}. */
+    public boolean setCharacteristicNotification(
+            BluetoothGattCharacteristic characteristic, boolean enable) {
+        return mWrappedBluetoothGatt.setCharacteristicNotification(characteristic, enable);
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#disconnect()}. */
+    public void disconnect() {
+        mWrappedBluetoothGatt.disconnect();
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#close()}. */
+    public void close() {
+        mWrappedBluetoothGatt.close();
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#hashCode()}. */
+    @Override
+    public int hashCode() {
+        return mWrappedBluetoothGatt.hashCode();
+    }
+
+    /** See {@link android.bluetooth.BluetoothGatt#equals(Object)}. */
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof BluetoothGattWrapper)) {
+            return false;
+        }
+        return mWrappedBluetoothGatt.equals(((BluetoothGattWrapper) o).unwrap());
+    }
+
+    /** Unwraps a Bluetooth Gatt instance. */
+    public android.bluetooth.BluetoothGatt unwrap() {
+        return mWrappedBluetoothGatt;
+    }
+
+    /** Wraps a Bluetooth Gatt instance. */
+    public static BluetoothGattWrapper wrap(android.bluetooth.BluetoothGatt gatt) {
+        return new BluetoothGattWrapper(gatt);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/BluetoothLeAdvertiser.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/BluetoothLeAdvertiser.java
new file mode 100644
index 0000000..6fe4432
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/BluetoothLeAdvertiser.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth.le;
+
+import android.annotation.TargetApi;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.os.Build;
+
+import javax.annotation.Nullable;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.le.BluetoothLeAdvertiser}.
+ */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+public class BluetoothLeAdvertiser {
+
+    private final android.bluetooth.le.BluetoothLeAdvertiser mWrappedInstance;
+
+    private BluetoothLeAdvertiser(
+            android.bluetooth.le.BluetoothLeAdvertiser bluetoothLeAdvertiser) {
+        mWrappedInstance = bluetoothLeAdvertiser;
+    }
+
+    /**
+     * See {@link android.bluetooth.le.BluetoothLeAdvertiser#startAdvertising(AdvertiseSettings,
+     * AdvertiseData, AdvertiseCallback)}.
+     */
+    public void startAdvertising(AdvertiseSettings settings, AdvertiseData advertiseData,
+            AdvertiseCallback callback) {
+        mWrappedInstance.startAdvertising(settings, advertiseData, callback);
+    }
+
+    /**
+     * See {@link android.bluetooth.le.BluetoothLeAdvertiser#startAdvertising(AdvertiseSettings,
+     * AdvertiseData, AdvertiseData, AdvertiseCallback)}.
+     */
+    public void startAdvertising(AdvertiseSettings settings, AdvertiseData advertiseData,
+            AdvertiseData scanResponse, AdvertiseCallback callback) {
+        mWrappedInstance.startAdvertising(settings, advertiseData, scanResponse, callback);
+    }
+
+    /**
+     * See {@link android.bluetooth.le.BluetoothLeAdvertiser#stopAdvertising(AdvertiseCallback)}.
+     */
+    public void stopAdvertising(AdvertiseCallback callback) {
+        mWrappedInstance.stopAdvertising(callback);
+    }
+
+    /** Wraps a Bluetooth LE advertiser. */
+    @Nullable
+    public static BluetoothLeAdvertiser wrap(
+            @Nullable android.bluetooth.le.BluetoothLeAdvertiser bluetoothLeAdvertiser) {
+        if (bluetoothLeAdvertiser == null) {
+            return null;
+        }
+        return new BluetoothLeAdvertiser(bluetoothLeAdvertiser);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/BluetoothLeScanner.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/BluetoothLeScanner.java
new file mode 100644
index 0000000..8a13abe
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/BluetoothLeScanner.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth.le;
+
+import android.annotation.TargetApi;
+import android.app.PendingIntent;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.os.Build;
+
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.le.BluetoothLeScanner}.
+ */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+public class BluetoothLeScanner {
+
+    private final android.bluetooth.le.BluetoothLeScanner mWrappedBluetoothLeScanner;
+
+    private BluetoothLeScanner(android.bluetooth.le.BluetoothLeScanner bluetoothLeScanner) {
+        mWrappedBluetoothLeScanner = bluetoothLeScanner;
+    }
+
+    /**
+     * See {@link android.bluetooth.le.BluetoothLeScanner#startScan(List, ScanSettings,
+     * android.bluetooth.le.ScanCallback)}.
+     */
+    public void startScan(List<ScanFilter> filters, ScanSettings settings,
+            ScanCallback callback) {
+        mWrappedBluetoothLeScanner.startScan(filters, settings, callback.unwrap());
+    }
+
+    /**
+     * See {@link android.bluetooth.le.BluetoothLeScanner#startScan(List, ScanSettings,
+     * PendingIntent)}.
+     */
+    public void startScan(
+            List<ScanFilter> filters, ScanSettings settings, PendingIntent callbackIntent) {
+        mWrappedBluetoothLeScanner.startScan(filters, settings, callbackIntent);
+    }
+
+    /**
+     * See {@link
+     * android.bluetooth.le.BluetoothLeScanner#startScan(android.bluetooth.le.ScanCallback)}.
+     */
+    public void startScan(ScanCallback callback) {
+        mWrappedBluetoothLeScanner.startScan(callback.unwrap());
+    }
+
+    /**
+     * See
+     * {@link android.bluetooth.le.BluetoothLeScanner#stopScan(android.bluetooth.le.ScanCallback)}.
+     */
+    public void stopScan(ScanCallback callback) {
+        mWrappedBluetoothLeScanner.stopScan(callback.unwrap());
+    }
+
+    /** See {@link android.bluetooth.le.BluetoothLeScanner#stopScan(PendingIntent)}. */
+    public void stopScan(PendingIntent callbackIntent) {
+        mWrappedBluetoothLeScanner.stopScan(callbackIntent);
+    }
+
+    /** Wraps a Bluetooth LE scanner. */
+    @Nullable
+    public static BluetoothLeScanner wrap(
+            @Nullable android.bluetooth.le.BluetoothLeScanner bluetoothLeScanner) {
+        if (bluetoothLeScanner == null) {
+            return null;
+        }
+        return new BluetoothLeScanner(bluetoothLeScanner);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/ScanCallback.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/ScanCallback.java
new file mode 100644
index 0000000..70926a7
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/ScanCallback.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth.le;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Wrapper of {@link android.bluetooth.le.ScanCallback} that uses mockable objects.
+ */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+public abstract class ScanCallback {
+
+    /** See {@link android.bluetooth.le.ScanCallback#SCAN_FAILED_ALREADY_STARTED} */
+    public static final int SCAN_FAILED_ALREADY_STARTED =
+            android.bluetooth.le.ScanCallback.SCAN_FAILED_ALREADY_STARTED;
+
+    /** See {@link android.bluetooth.le.ScanCallback#SCAN_FAILED_APPLICATION_REGISTRATION_FAILED} */
+    public static final int SCAN_FAILED_APPLICATION_REGISTRATION_FAILED =
+            android.bluetooth.le.ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED;
+
+    /** See {@link android.bluetooth.le.ScanCallback#SCAN_FAILED_FEATURE_UNSUPPORTED} */
+    public static final int SCAN_FAILED_FEATURE_UNSUPPORTED =
+            android.bluetooth.le.ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED;
+
+    /** See {@link android.bluetooth.le.ScanCallback#SCAN_FAILED_INTERNAL_ERROR} */
+    public static final int SCAN_FAILED_INTERNAL_ERROR =
+            android.bluetooth.le.ScanCallback.SCAN_FAILED_INTERNAL_ERROR;
+
+    private final android.bluetooth.le.ScanCallback mWrappedScanCallback =
+            new InternalScanCallback();
+
+    /**
+     * See {@link android.bluetooth.le.ScanCallback#onScanFailed(int)}
+     */
+    public void onScanFailed(int errorCode) {}
+
+    /**
+     * See
+     * {@link android.bluetooth.le.ScanCallback#onScanResult(int, android.bluetooth.le.ScanResult)}.
+     */
+    public void onScanResult(int callbackType, ScanResult result) {}
+
+    /**
+     * See {@link
+     * android.bluetooth.le.ScanCallback#onBatchScanResult(List<android.bluetooth.le.ScanResult>)}.
+     */
+    public void onBatchScanResults(List<ScanResult> results) {}
+
+    /** Unwraps scan callback. */
+    public android.bluetooth.le.ScanCallback unwrap() {
+        return mWrappedScanCallback;
+    }
+
+    /** Forward callback to testable instance. */
+    private class InternalScanCallback extends android.bluetooth.le.ScanCallback {
+        @Override
+        public void onScanFailed(int errorCode) {
+            ScanCallback.this.onScanFailed(errorCode);
+        }
+
+        @Override
+        public void onScanResult(int callbackType, android.bluetooth.le.ScanResult result) {
+            ScanCallback.this.onScanResult(callbackType, ScanResult.wrap(result));
+        }
+
+        @Override
+        public void onBatchScanResults(List<android.bluetooth.le.ScanResult> results) {
+            List<ScanResult> wrappedScanResults = new ArrayList<>();
+            for (android.bluetooth.le.ScanResult result : results) {
+                wrappedScanResults.add(ScanResult.wrap(result));
+            }
+            ScanCallback.this.onBatchScanResults(wrappedScanResults);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/ScanResult.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/ScanResult.java
new file mode 100644
index 0000000..1a6b7b3
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/le/ScanResult.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 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.common.bluetooth.testability.android.bluetooth.le;
+
+import android.annotation.TargetApi;
+import android.bluetooth.le.ScanRecord;
+import android.os.Build;
+
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+
+import javax.annotation.Nullable;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.le.ScanResult}.
+ */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+public class ScanResult {
+
+    private final android.bluetooth.le.ScanResult mWrappedScanResult;
+
+    private ScanResult(android.bluetooth.le.ScanResult scanResult) {
+        mWrappedScanResult = scanResult;
+    }
+
+    /** See {@link android.bluetooth.le.ScanResult#getScanRecord()}. */
+    @Nullable
+    public ScanRecord getScanRecord() {
+        return mWrappedScanResult.getScanRecord();
+    }
+
+    /** See {@link android.bluetooth.le.ScanResult#getRssi()}. */
+    public int getRssi() {
+        return mWrappedScanResult.getRssi();
+    }
+
+    /** See {@link android.bluetooth.le.ScanResult#getTimestampNanos()}. */
+    public long getTimestampNanos() {
+        return mWrappedScanResult.getTimestampNanos();
+    }
+
+    /** See {@link android.bluetooth.le.ScanResult#getDevice()}. */
+    public BluetoothDevice getDevice() {
+        return BluetoothDevice.wrap(mWrappedScanResult.getDevice());
+    }
+
+    /** Creates a wrapper of scan result. */
+    public static ScanResult wrap(android.bluetooth.le.ScanResult scanResult) {
+        return new ScanResult(scanResult);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtils.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtils.java
new file mode 100644
index 0000000..bb51920
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtils.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 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.common.bluetooth.util;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+
+import javax.annotation.Nullable;
+
+/**
+ * Utils for Gatt profile.
+ */
+public class BluetoothGattUtils {
+
+    /**
+     * Returns a string message for a BluetoothGatt status codes.
+     */
+    public static String getMessageForStatusCode(int statusCode) {
+        switch (statusCode) {
+            case BluetoothGatt.GATT_SUCCESS:
+                return "GATT_SUCCESS";
+            case BluetoothGatt.GATT_FAILURE:
+                return "GATT_FAILURE";
+            case BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION:
+                return "GATT_INSUFFICIENT_AUTHENTICATION";
+            case BluetoothGatt.GATT_INSUFFICIENT_AUTHORIZATION:
+                return "GATT_INSUFFICIENT_AUTHORIZATION";
+            case BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION:
+                return "GATT_INSUFFICIENT_ENCRYPTION";
+            case BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH:
+                return "GATT_INVALID_ATTRIBUTE_LENGTH";
+            case BluetoothGatt.GATT_INVALID_OFFSET:
+                return "GATT_INVALID_OFFSET";
+            case BluetoothGatt.GATT_READ_NOT_PERMITTED:
+                return "GATT_READ_NOT_PERMITTED";
+            case BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED:
+                return "GATT_REQUEST_NOT_SUPPORTED";
+            case BluetoothGatt.GATT_WRITE_NOT_PERMITTED:
+                return "GATT_WRITE_NOT_PERMITTED";
+            case BluetoothGatt.GATT_CONNECTION_CONGESTED:
+                return "GATT_CONNECTION_CONGESTED";
+            default:
+                return "Unknown error code";
+        }
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattDescriptor}. */
+    public static String toString(@Nullable BluetoothGattDescriptor descriptor) {
+        if (descriptor == null) {
+            return "null descriptor";
+        }
+        return String.format("descriptor %s on %s",
+                descriptor.getUuid(),
+                toString(descriptor.getCharacteristic()));
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattCharacteristic}. */
+    public static String toString(@Nullable BluetoothGattCharacteristic characteristic) {
+        if (characteristic == null) {
+            return "null characteristic";
+        }
+        return String.format("characteristic %s on %s",
+                characteristic.getUuid(),
+                toString(characteristic.getService()));
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattService}. */
+    public static String toString(@Nullable BluetoothGattService service) {
+        if (service == null) {
+            return "null service";
+        }
+        return String.format("service %s", service.getUuid());
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/util/BluetoothOperationExecutor.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/util/BluetoothOperationExecutor.java
new file mode 100644
index 0000000..fecf483
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/util/BluetoothOperationExecutor.java
@@ -0,0 +1,548 @@
+/*
+ * Copyright 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.common.bluetooth.util;
+
+import android.bluetooth.BluetoothGatt;
+import android.util.Log;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.testability.NonnullProvider;
+import com.android.server.nearby.common.bluetooth.testability.TimeProvider;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Objects;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.annotation.Nullable;
+
+/**
+ * Scheduler to coordinate parallel bluetooth operations.
+ */
+public class BluetoothOperationExecutor {
+
+    private static final String TAG = BluetoothOperationExecutor.class.getSimpleName();
+
+    /**
+     * Special value to indicate that the result is null (since {@link BlockingQueue} doesn't allow
+     * null elements).
+     */
+    private static final Object NULL_RESULT = new Object();
+
+    /**
+     * Special value to indicate that there should be no timeout on the operation.
+     */
+    private static final long NO_TIMEOUT = -1;
+
+    private final NonnullProvider<BlockingQueue<Object>> mBlockingQueueProvider;
+    private final TimeProvider mTimeProvider;
+    @VisibleForTesting
+    final Map<Operation<?>, Queue<Object>> mOperationResultQueues = new HashMap<>();
+    private final Semaphore mOperationSemaphore;
+
+    /**
+     * New instance that limits concurrent operations to maxConcurrentOperations.
+     */
+    public BluetoothOperationExecutor(int maxConcurrentOperations) {
+        this(
+                new Semaphore(maxConcurrentOperations, true),
+                new TimeProvider(),
+                new NonnullProvider<BlockingQueue<Object>>() {
+                    @Override
+                    public BlockingQueue<Object> get() {
+                        return new LinkedBlockingDeque<Object>();
+                    }
+                });
+    }
+
+    /**
+     * Constructor for unit tests.
+     */
+    @VisibleForTesting
+    BluetoothOperationExecutor(Semaphore operationSemaphore,
+            TimeProvider timeProvider,
+            NonnullProvider<BlockingQueue<Object>> blockingQueueProvider) {
+        mOperationSemaphore = operationSemaphore;
+        mTimeProvider = timeProvider;
+        mBlockingQueueProvider = blockingQueueProvider;
+    }
+
+    /**
+     * Executes the operation and waits for its completion.
+     */
+    @Nullable
+    public <T> T execute(Operation<T> operation) throws BluetoothException {
+        return getResult(schedule(operation));
+    }
+
+    /**
+     * Executes the operation and waits for its completion and returns a non-null result.
+     */
+    public <T> T executeNonnull(Operation<T> operation) throws BluetoothException {
+        T result = getResult(schedule(operation));
+        if (result == null) {
+            throw new BluetoothException(
+                    String.format(Locale.US, "Operation %s returned a null result.", operation));
+        }
+        return result;
+    }
+
+    /**
+     * Executes the operation and waits for its completion with a timeout.
+     */
+    @Nullable
+    public <T> T execute(Operation<T> bluetoothOperation, long timeoutMillis)
+            throws BluetoothException, BluetoothOperationTimeoutException {
+        return getResult(schedule(bluetoothOperation), timeoutMillis);
+    }
+
+    /**
+     * Executes the operation and waits for its completion with a timeout and returns a non-null
+     * result.
+     */
+    public <T> T executeNonnull(Operation<T> bluetoothOperation, long timeoutMillis)
+            throws BluetoothException {
+        T result = getResult(schedule(bluetoothOperation), timeoutMillis);
+        if (result == null) {
+            throw new BluetoothException(
+                    String.format(Locale.US, "Operation %s returned a null result.",
+                            bluetoothOperation));
+        }
+        return result;
+    }
+
+    /**
+     * Schedules an operation and returns a {@link Future} that waits on operation completion and
+     * gets its result.
+     */
+    public <T> Future<T> schedule(Operation<T> bluetoothOperation) {
+        BlockingQueue<Object> resultQueue = mBlockingQueueProvider.get();
+        mOperationResultQueues.put(bluetoothOperation, resultQueue);
+
+        boolean semaphoreAcquired = mOperationSemaphore.tryAcquire();
+        Log.d(TAG, String.format(Locale.US,
+                "Scheduling operation %s; %d permits available; Semaphore acquired: %b",
+                bluetoothOperation,
+                mOperationSemaphore.availablePermits(),
+                semaphoreAcquired));
+
+        if (semaphoreAcquired) {
+            bluetoothOperation.execute(this);
+        }
+        return new BluetoothOperationFuture<T>(resultQueue, bluetoothOperation, semaphoreAcquired);
+    }
+
+    /**
+     * Notifies that this operation has completed with success.
+     */
+    public void notifySuccess(Operation<Void> bluetoothOperation) {
+        postResult(bluetoothOperation, null);
+    }
+
+    /**
+     * Notifies that this operation has completed with success and with a result.
+     */
+    public <T> void notifySuccess(Operation<T> bluetoothOperation, T result) {
+        postResult(bluetoothOperation, result);
+    }
+
+    /**
+     * Notifies that this operation has completed with the given BluetoothGatt status code (which
+     * may indicate success or failure).
+     */
+    public void notifyCompletion(Operation<Void> bluetoothOperation, int status) {
+        notifyCompletion(bluetoothOperation, status, null);
+    }
+
+    /**
+     * Notifies that this operation has completed with the given BluetoothGatt status code (which
+     * may indicate success or failure) and with a result.
+     */
+    public <T> void notifyCompletion(Operation<T> bluetoothOperation, int status,
+            @Nullable T result) {
+        if (status != BluetoothGatt.GATT_SUCCESS) {
+            notifyFailure(bluetoothOperation, new BluetoothGattException(
+                    String.format(Locale.US,
+                            "Operation %s failed: %d - %s.", bluetoothOperation, status,
+                            BluetoothGattUtils.getMessageForStatusCode(status)),
+                    status));
+            return;
+        }
+        postResult(bluetoothOperation, result);
+    }
+
+    /**
+     * Notifies that this operation has completed with failure.
+     */
+    public void notifyFailure(Operation<?> bluetoothOperation, BluetoothException exception) {
+        postResult(bluetoothOperation, exception);
+    }
+
+    private void postResult(Operation<?> bluetoothOperation, @Nullable Object result) {
+        Queue<Object> resultQueue = mOperationResultQueues.get(bluetoothOperation);
+        if (resultQueue == null) {
+            Log.e(TAG, String.format(Locale.US,
+                    "Receive completion for unexpected operation: %s.", bluetoothOperation));
+            return;
+        }
+        resultQueue.add(result == null ? NULL_RESULT : result);
+        mOperationResultQueues.remove(bluetoothOperation);
+        mOperationSemaphore.release();
+        Log.d(TAG, String.format(Locale.US,
+                "Released semaphore for operation %s. There are %d permits left",
+                bluetoothOperation, mOperationSemaphore.availablePermits()));
+    }
+
+    /**
+     * Waits for all future on the list to complete, ignoring the results.
+     */
+    public <T> void waitFor(List<Future<T>> futures) throws BluetoothException {
+        for (Future<T> future : futures) {
+            if (future == null) {
+                continue;
+            }
+            getResult(future);
+        }
+    }
+
+    /**
+     * Waits with timeout for all future on the list to complete, ignoring the results.
+     */
+    public <T> void waitFor(List<Future<T>> futures, long timeoutMillis)
+            throws BluetoothException {
+        long startTime = mTimeProvider.getTimeMillis();
+        for (Future<T> future : futures) {
+            if (future == null) {
+                continue;
+            }
+            getResult(future,
+                    timeoutMillis - (mTimeProvider.getTimeMillis() - startTime));
+        }
+    }
+
+    /**
+     * Waits for a future to complete and returns the result.
+     */
+    @Nullable
+    public static <T> T getResult(Future<T> future) throws BluetoothException {
+        return getResultInternal(future, NO_TIMEOUT);
+    }
+
+    /**
+     * Waits for a future to complete and returns the result with timeout.
+     */
+    @Nullable
+    public static <T> T getResult(Future<T> future, long timeoutMillis) throws BluetoothException {
+        return getResultInternal(future, Math.max(0, timeoutMillis));
+    }
+
+    @Nullable
+    private static <T> T getResultInternal(Future<T> future, long timeoutMillis)
+            throws BluetoothException {
+        try {
+            if (timeoutMillis == NO_TIMEOUT) {
+                return future.get();
+            } else {
+                return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
+            }
+        } catch (InterruptedException e) {
+            try {
+                boolean cancelSuccess = future.cancel(true);
+                if (!cancelSuccess && future.isDone()) {
+                    // Operation has succeeded before we send cancel to it.
+                    return getResultInternal(future, NO_TIMEOUT);
+                }
+            } finally {
+                // Re-interrupt the thread last since we're recursively calling getResultInternal.
+                // We know the future is done, so there's no need to be interrupted while we call.
+                Thread.currentThread().interrupt();
+            }
+            throw new BluetoothException("Wait interrupted");
+        } catch (ExecutionException e) {
+            Throwable cause = e.getCause();
+            if (cause instanceof BluetoothException) {
+                throw (BluetoothException) cause;
+            }
+            throw new RuntimeException(e);
+        } catch (TimeoutException e) {
+            boolean cancelSuccess = future.cancel(true);
+            if (!cancelSuccess && future.isDone()) {
+                // Operation has succeeded before we send cancel to it.
+                return getResultInternal(future, NO_TIMEOUT);
+            }
+            throw new BluetoothOperationTimeoutException(
+                    String.format(Locale.US, "Wait timed out after %s ms.", timeoutMillis), e);
+        }
+    }
+
+    /**
+     * Asynchronous bluetooth operation to schedule.
+     *
+     * <p>An instance that doesn't implemented run() can be used to notify operation result.
+     *
+     * @param <T> Type of provided instance.
+     */
+    public static class Operation<T> {
+
+        private Object[] mElements;
+
+        public Operation(Object... elements) {
+            mElements = elements;
+        }
+
+        /**
+         * Executes operation using executor.
+         */
+        public void execute(BluetoothOperationExecutor executor) {
+            try {
+                run();
+            } catch (BluetoothException e) {
+                executor.postResult(this, e);
+            }
+        }
+
+        /**
+         * Run function. Not supported.
+         */
+        @SuppressWarnings("unused")
+        public void run() throws BluetoothException {
+            throw new RuntimeException("Not implemented");
+        }
+
+        /**
+         * Try to cancel operation when a timeout occurs.
+         */
+        public void cancel() {
+        }
+
+        @Override
+        public boolean equals(@Nullable Object o) {
+            if (o == null) {
+                return false;
+            }
+            if (!Operation.class.isInstance(o)) {
+                return false;
+            }
+            Operation<?> other = (Operation<?>) o;
+            return Arrays.equals(mElements, other.mElements);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(mElements);
+        }
+
+        @Override
+        public String toString() {
+            return Joiner.on('-').join(mElements);
+        }
+    }
+
+    /**
+     * Synchronous bluetooth operation to schedule.
+     *
+     * @param <T> Type of provided instance.
+     */
+    public static class SynchronousOperation<T> extends Operation<T> {
+
+        public SynchronousOperation(Object... elements) {
+            super(elements);
+        }
+
+        @Override
+        public void execute(BluetoothOperationExecutor executor) {
+            try {
+                Object result = call();
+                if (result == null) {
+                    result = NULL_RESULT;
+                }
+                executor.postResult(this, result);
+            } catch (BluetoothException e) {
+                executor.postResult(this, e);
+            }
+        }
+
+        /**
+         * Call function. Not supported.
+         */
+        @SuppressWarnings("unused")
+        @Nullable
+        public T call() throws BluetoothException {
+            throw new RuntimeException("Not implemented");
+        }
+    }
+
+    /**
+     * {@link Future} to wait / get result of an operation.
+     *
+     * <li>Waits for operation to complete
+     * <li>Handles timeouts if needed
+     * <li>Queues identical Bluetooth operations
+     * <li>Unwraps Exceptions and null values
+     */
+    private class BluetoothOperationFuture<T> implements Future<T> {
+
+        private final Object mLock = new Object();
+
+        /**
+         * Queue that will be used to store the result. It should normally contains one element
+         * maximum, but using a queue avoid some race conditions.
+         */
+        private final BlockingQueue<Object> mResultQueue;
+        private final Operation<T> mBluetoothOperation;
+        private final boolean mOperationExecuted;
+        private boolean mIsCancelled = false;
+        private boolean mIsDone = false;
+
+        BluetoothOperationFuture(BlockingQueue<Object> resultQueue,
+                Operation<T> bluetoothOperation, boolean operationExecuted) {
+            mResultQueue = resultQueue;
+            mBluetoothOperation = bluetoothOperation;
+            mOperationExecuted = operationExecuted;
+        }
+
+        @Override
+        public boolean cancel(boolean mayInterruptIfRunning) {
+            synchronized (mLock) {
+                if (mIsDone) {
+                    return false;
+                }
+                if (mIsCancelled) {
+                    return true;
+                }
+                mBluetoothOperation.cancel();
+                mIsCancelled = true;
+                notifyFailure(mBluetoothOperation, new BluetoothException("Operation cancelled."));
+                return true;
+            }
+        }
+
+        @Override
+        public boolean isCancelled() {
+            synchronized (mLock) {
+                return mIsCancelled;
+            }
+        }
+
+        @Override
+        public boolean isDone() {
+            synchronized (mLock) {
+                return mIsDone;
+            }
+        }
+
+        @Override
+        @Nullable
+        public T get() throws InterruptedException, ExecutionException {
+            try {
+                return getInternal(NO_TIMEOUT, TimeUnit.MILLISECONDS);
+            } catch (TimeoutException e) {
+                throw new RuntimeException(e); // This is not supposed to be thrown
+            }
+        }
+
+        @Override
+        @Nullable
+        public T get(long timeoutMillis, TimeUnit unit)
+                throws InterruptedException, ExecutionException, TimeoutException {
+            return getInternal(Math.max(0, timeoutMillis), unit);
+        }
+
+        @SuppressWarnings("unchecked")
+        @Nullable
+        private T getInternal(long timeoutMillis, TimeUnit unit)
+                throws ExecutionException, InterruptedException, TimeoutException {
+            // Prevent parallel executions of this method.
+            long startTime = mTimeProvider.getTimeMillis();
+            synchronized (this) {
+                synchronized (mLock) {
+                    if (mIsDone) {
+                        throw new ExecutionException(
+                                new BluetoothException("get() called twice..."));
+                    }
+                }
+                if (!mOperationExecuted) {
+                    if (timeoutMillis == NO_TIMEOUT) {
+                        mOperationSemaphore.acquire();
+                    } else {
+                        if (!mOperationSemaphore.tryAcquire(timeoutMillis
+                                - (mTimeProvider.getTimeMillis() - startTime), unit)) {
+                            throw new TimeoutException(String.format(Locale.US,
+                                    "A timeout occurred when processing %s after %s %s.",
+                                    mBluetoothOperation, timeoutMillis, unit));
+                        }
+                    }
+                    mBluetoothOperation.execute(BluetoothOperationExecutor.this);
+                }
+                Object result;
+
+                if (timeoutMillis == NO_TIMEOUT) {
+                    result = mResultQueue.take();
+                } else {
+                    result = mResultQueue.poll(
+                            timeoutMillis - (mTimeProvider.getTimeMillis() - startTime), unit);
+                }
+
+                if (result == null) {
+                    throw new TimeoutException(String.format(Locale.US,
+                            "A timeout occurred when processing %s after %s ms.",
+                            mBluetoothOperation, timeoutMillis));
+                }
+                synchronized (mLock) {
+                    mIsDone = true;
+                }
+                if (result instanceof BluetoothException) {
+                    throw new ExecutionException((BluetoothException) result);
+                }
+                if (result == NULL_RESULT) {
+                    result = null;
+                }
+                return (T) result;
+            }
+        }
+    }
+
+    /**
+     * Exception thrown when an operation execution times out. Since state of the system is unknown
+     * afterward (operation may still complete or not), it is recommended to disconnect and
+     * reconnect.
+     */
+    public static class BluetoothOperationTimeoutException extends BluetoothException {
+
+        public BluetoothOperationTimeoutException(String message) {
+            super(message);
+        }
+
+        public BluetoothOperationTimeoutException(String message, Throwable cause) {
+            super(message, cause);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/eventloop/Annotations.java b/nearby/service/java/com/android/server/nearby/common/eventloop/Annotations.java
new file mode 100644
index 0000000..44c9422
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/eventloop/Annotations.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 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.common.eventloop;
+
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.CLASS;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.BinderThread;
+import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A collection of threading annotations relating to EventLoop. These should be used in conjunction
+ * with {@link UiThread}, {@link BinderThread}, {@link WorkerThread}, and {@link AnyThread}.
+ */
+public class Annotations {
+
+    /**
+     * Denotes that the annotated method or constructor should only be called on the EventLoop
+     * thread.
+     */
+    @Retention(CLASS)
+    @Target({METHOD, CONSTRUCTOR, TYPE})
+    public @interface EventThread {
+    }
+
+    /** Denotes that the annotated method or constructor should only be called on a Network
+     * thread. */
+    @Retention(CLASS)
+    @Target({METHOD, CONSTRUCTOR, TYPE})
+    public @interface NetworkThread {
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/eventloop/EventLoop.java b/nearby/service/java/com/android/server/nearby/common/eventloop/EventLoop.java
new file mode 100644
index 0000000..c89366f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/eventloop/EventLoop.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 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.common.eventloop;
+
+import android.annotation.Nullable;
+import android.os.Handler;
+import android.os.Looper;
+
+/**
+ * Handles executing runnables on a background thread.
+ *
+ * <p>Nearby services follow an event loop model where events can be queued and delivered in the
+ * future. All code that is run in this EventLoop is guaranteed to be run on this thread. The main
+ * advantage of this model is that all modules don't have to deal with synchronization and race
+ * conditions, while making it easy to handle the several asynchronous tasks that are expected to be
+ * needed for this type of provider (such as starting a WiFi scan and waiting for the result,
+ * starting BLE scans, doing a server request and waiting for the response etc.).
+ *
+ * <p>Code that needs to wait for an event should not spawn a new thread nor sleep. It should simply
+ * deliver a new message to the event queue when the reply of the event happens.
+ */
+// TODO(b/177675274): Resolve nullness suppression.
+@SuppressWarnings("nullness")
+public class EventLoop {
+
+    private final Interface mImpl;
+
+    private EventLoop(Interface impl) {
+        this.mImpl = impl;
+    }
+
+    protected EventLoop(String name) {
+        this(new HandlerEventLoopImpl(name));
+    }
+
+    /** Creates an EventLoop. */
+    public static EventLoop newInstance(String name) {
+        return new EventLoop(name);
+    }
+
+    /** Creates an EventLoop. */
+    public static EventLoop newInstance(String name, Looper looper) {
+        return new EventLoop(new HandlerEventLoopImpl(name, looper));
+    }
+
+    /** Marks the EventLoop as destroyed. Any further messages received will be ignored. */
+    public void destroy() {
+        mImpl.destroy();
+    }
+
+    /**
+     * Posts a runnable to this event loop, blocking until the runnable has been executed. This
+     * should
+     * be used rarely. It could be useful, for example, for a runnable that initializes the system
+     * and
+     * must block the posting of all other runnables.
+     *
+     * @param runnable a Runnable to post. This method will not return until the run() method of the
+     *                 given runnable has executed on the background thread.
+     */
+    public void postAndWait(final NamedRunnable runnable) throws InterruptedException {
+        mImpl.postAndWait(runnable);
+    }
+
+    /**
+     * Posts a runnable to this to the front of the event loop, blocking until the runnable has been
+     * executed. This should be used rarely, as it can starve the event loop.
+     *
+     * @param runnable a Runnable to post. This method will not return until the run() method of the
+     *                 given runnable has executed on the background thread.
+     */
+    public void postToFrontAndWait(final NamedRunnable runnable) throws InterruptedException {
+        mImpl.postToFrontAndWait(runnable);
+    }
+
+    /** Checks if there are any pending posts of the Runnable in the queue. */
+    public boolean isPosted(NamedRunnable runnable) {
+        return mImpl.isPosted(runnable);
+    }
+
+    /**
+     * Run code on the event loop thread.
+     *
+     * @param runnable the runnable to execute.
+     */
+    public void postRunnable(NamedRunnable runnable) {
+        mImpl.postRunnable(runnable);
+    }
+
+    /**
+     * Run code to be executed when there is no runnable scheduled.
+     *
+     * @param runnable last runnable to execute.
+     */
+    public void postEmptyQueueRunnable(final NamedRunnable runnable) {
+        mImpl.postEmptyQueueRunnable(runnable);
+    }
+
+    /**
+     * Run code on the event loop thread after delayedMillis.
+     *
+     * @param runnable      the runnable to execute.
+     * @param delayedMillis the number of milliseconds before executing the runnable.
+     */
+    public void postRunnableDelayed(NamedRunnable runnable, long delayedMillis) {
+        mImpl.postRunnableDelayed(runnable, delayedMillis);
+    }
+
+    /**
+     * Removes and cancels the specified {@code runnable} if it had not posted/started yet. Calling
+     * with null does nothing.
+     */
+    public void removeRunnable(@Nullable NamedRunnable runnable) {
+        mImpl.removeRunnable(runnable);
+    }
+
+    /** Asserts that the current operation is being executed in the Event Loop's thread. */
+    public void checkThread() {
+        mImpl.checkThread();
+    }
+
+    public Handler getHandler() {
+        return mImpl.getHandler();
+    }
+
+    interface Interface {
+        void destroy();
+
+        void postAndWait(NamedRunnable runnable) throws InterruptedException;
+
+        void postToFrontAndWait(NamedRunnable runnable) throws InterruptedException;
+
+        boolean isPosted(NamedRunnable runnable);
+
+        void postRunnable(NamedRunnable runnable);
+
+        void postEmptyQueueRunnable(NamedRunnable runnable);
+
+        void postRunnableDelayed(NamedRunnable runnable, long delayedMillis);
+
+        void removeRunnable(NamedRunnable runnable);
+
+        void checkThread();
+
+        Handler getHandler();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/eventloop/HandlerEventLoopImpl.java b/nearby/service/java/com/android/server/nearby/common/eventloop/HandlerEventLoopImpl.java
new file mode 100644
index 0000000..018dcdb
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/eventloop/HandlerEventLoopImpl.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright 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.common.eventloop;
+
+import android.annotation.Nullable;
+import android.annotation.SuppressLint;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.os.Process;
+import android.os.SystemClock;
+import android.util.Log;
+
+import java.text.SimpleDateFormat;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+
+/**
+ * Handles executing runnables on a background thread.
+ *
+ * <p>Nearby services follow an event loop model where events can be queued and delivered in the
+ * future. All code that is run in this package is guaranteed to be run on this thread. The main
+ * advantage of this model is that all modules don't have to deal with synchronization and race
+ * conditions, while making it easy to handle the several asynchronous tasks that are expected to be
+ * needed for this type of provider (such as starting a WiFi scan and waiting for the result,
+ * starting BLE scans, doing a server request and waiting for the response etc.).
+ *
+ * <p>Code that needs to wait for an event should not spawn a new thread nor sleep. It should simply
+ * deliver a new message to the event queue when the reply of the event happens.
+ *
+ * <p>
+ */
+// TODO(b/203471261) use executor instead of handler
+// TODO(b/177675274): Resolve nullness suppression.
+@SuppressWarnings("nullness")
+final class HandlerEventLoopImpl implements EventLoop.Interface {
+    /** The {@link Message#what} code for all messages that we post to the EventLoop. */
+    private static final int WHAT = 0;
+
+    private static final long ELAPSED_THRESHOLD_MS = TimeUnit.SECONDS.toMillis(5);
+    private static final long RUNNABLE_DELAY_THRESHOLD_MS = TimeUnit.SECONDS.toMillis(2);
+    private static final String TAG = HandlerEventLoopImpl.class.getSimpleName();
+    private final MyHandler mHandler;
+
+    private volatile boolean mIsDestroyed = false;
+
+    /** Constructs an EventLoop. */
+    HandlerEventLoopImpl(String name) {
+        this(name, createHandlerThread(name));
+    }
+
+    HandlerEventLoopImpl(String name, Looper looper) {
+
+        mHandler = new MyHandler(looper);
+        Log.d(TAG,
+                "Created EventLoop for thread '" + looper.getThread().getName()
+                        + "(id: " + looper.getThread().getId() + ")'");
+    }
+
+    private static Looper createHandlerThread(String name) {
+        HandlerThread handlerThread = new HandlerThread(name, Process.THREAD_PRIORITY_BACKGROUND);
+        handlerThread.start();
+
+        return handlerThread.getLooper();
+    }
+
+    /**
+     * Wrapper to satisfy Android Lint. {@link Looper#getQueue()} is public and available since ICS,
+     * but was marked @hide until Marshmallow. Tested that this code doesn't crash pre-Marshmallow.
+     * /aosp-ics/frameworks/base/core/java/android/os/Looper.java?l=218
+     */
+    @SuppressLint("NewApi")
+    private static MessageQueue getQueue(Handler handler) {
+        return handler.getLooper().getQueue();
+    }
+
+    /** Marks the EventLoop as destroyed. Any further messages received will be ignored. */
+    @Override
+    public void destroy() {
+        Looper looper = mHandler.getLooper();
+        Log.d(TAG,
+                "Destroying EventLoop for thread " + looper.getThread().getName()
+                        + " (id: " + looper.getThread().getId() + ")");
+        looper.quit();
+        mIsDestroyed = true;
+    }
+
+    /**
+     * Posts a runnable to this event loop, blocking until the runnable has been executed. This
+     * should
+     * be used rarely. It could be useful, for example, for a runnable that initializes the system
+     * and
+     * must block the posting of all other runnables.
+     *
+     * @param runnable a Runnable to post. This method will not return until the run() method of the
+     *                 given runnable has executed on the background thread.
+     */
+    @Override
+    public void postAndWait(final NamedRunnable runnable) throws InterruptedException {
+        internalPostAndWait(runnable, false);
+    }
+
+    @Override
+    public void postToFrontAndWait(final NamedRunnable runnable) throws InterruptedException {
+        internalPostAndWait(runnable, true);
+    }
+
+    /** Checks if there are any pending posts of the Runnable in the queue. */
+    @Override
+    public boolean isPosted(NamedRunnable runnable) {
+        return mHandler.hasMessages(WHAT, runnable);
+    }
+
+    /**
+     * Run code on the event loop thread.
+     *
+     * @param runnable the runnable to execute.
+     */
+    @Override
+    public void postRunnable(NamedRunnable runnable) {
+        Log.d(TAG, "Posting " + runnable);
+        mHandler.post(runnable, 0L, false);
+    }
+
+    /**
+     * Run code to be executed when there is no runnable scheduled.
+     *
+     * @param runnable last runnable to execute.
+     */
+    @Override
+    public void postEmptyQueueRunnable(final NamedRunnable runnable) {
+        mHandler.post(
+                () ->
+                        getQueue(mHandler)
+                                .addIdleHandler(
+                                        () -> {
+                                            if (mHandler.hasMessages(WHAT)) {
+                                                return true;
+                                            } else {
+                                                // Only stop if start has not been called since
+                                                // this was queued
+                                                runnable.run();
+                                                return false;
+                                            }
+                                        }));
+    }
+
+    /**
+     * Run code on the event loop thread after delayedMillis.
+     *
+     * @param runnable      the runnable to execute.
+     * @param delayedMillis the number of milliseconds before executing the runnable.
+     */
+    @Override
+    public void postRunnableDelayed(NamedRunnable runnable, long delayedMillis) {
+        Log.d(TAG, "Posting " + runnable + " [delay " + delayedMillis + "]");
+        mHandler.post(runnable, delayedMillis, false);
+    }
+
+    /**
+     * Removes and cancels the specified {@code runnable} if it had not posted/started yet. Calling
+     * with null does nothing.
+     */
+    @Override
+    public void removeRunnable(@Nullable NamedRunnable runnable) {
+        if (runnable != null) {
+            // Removes any pending sent messages where what=WHAT and obj=runnable. We can't use
+            // removeCallbacks(runnable) because we're not posting the runnable directly, we're
+            // sending a Message with the runnable as its obj.
+            mHandler.removeMessages(WHAT, runnable);
+        }
+    }
+
+    /** Asserts that the current operation is being executed in the Event Loop's thread. */
+    @Override
+    public void checkThread() {
+
+        Thread currentThread = Looper.myLooper().getThread();
+        Thread expectedThread = mHandler.getLooper().getThread();
+        if (currentThread.getId() != expectedThread.getId()) {
+            throw new IllegalStateException(
+                    String.format(
+                            "This method must run in the EventLoop thread '%s (id: %s)'. "
+                                    + "Was called from thread '%s (id: %s)'.",
+                            expectedThread.getName(),
+                            expectedThread.getId(),
+                            currentThread.getName(),
+                            currentThread.getId()));
+        }
+
+    }
+
+    @Override
+    public Handler getHandler() {
+        return mHandler;
+    }
+
+    private void internalPostAndWait(final NamedRunnable runnable, boolean postToFront)
+            throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        NamedRunnable delegate =
+                new NamedRunnable(runnable.name) {
+                    @Override
+                    public void run() {
+                        try {
+                            runnable.run();
+                        } finally {
+                            latch.countDown();
+                        }
+                    }
+                };
+
+        Log.d(TAG, "Posting " + delegate + " and wait");
+        if (!mHandler.post(delegate, 0L, postToFront)) {
+            // Do not wait if delegate is not posted.
+            Log.d(TAG, delegate + " not posted");
+            latch.countDown();
+        }
+        latch.await();
+    }
+
+    /** Handler that executes code on a private event loop thread. */
+    private class MyHandler extends Handler {
+
+        MyHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            NamedRunnable runnable = (NamedRunnable) msg.obj;
+
+            if (mIsDestroyed) {
+                Log.w(TAG, "Runnable " + runnable
+                        + " attempted to run after the EventLoop was destroyed. Ignoring");
+                return;
+            }
+            Log.i(TAG, "Executing " + runnable);
+
+            // Did this runnable start much later than we expected it to? If so, then log.
+            long expectedStartTime = (long) msg.arg1 << 32 | (msg.arg2 & 0xFFFFFFFFL);
+            logIfExceedsThreshold(
+                    RUNNABLE_DELAY_THRESHOLD_MS, expectedStartTime, runnable, "was delayed for");
+
+            long startTimeMillis = SystemClock.elapsedRealtime();
+            try {
+                runnable.run();
+            } catch (Exception t) {
+                Log.e(TAG, runnable + "crashed.");
+                throw t;
+            } finally {
+                logIfExceedsThreshold(ELAPSED_THRESHOLD_MS, startTimeMillis, runnable, "ran for");
+            }
+        }
+
+        private boolean post(NamedRunnable runnable, long delayedMillis, boolean postToFront) {
+            if (mIsDestroyed) {
+                Log.w(TAG, runnable + " not posted since EventLoop is destroyed");
+                return false;
+            }
+            long expectedStartTime = SystemClock.elapsedRealtime() + delayedMillis;
+            int arg1 = (int) (expectedStartTime >> 32);
+            int arg2 = (int) expectedStartTime;
+            Message message = obtainMessage(WHAT, arg1, arg2, runnable /* obj */);
+            boolean sent =
+                    postToFront
+                            ? sendMessageAtFrontOfQueue(message)
+                            : sendMessageDelayed(message, delayedMillis);
+            if (!sent) {
+                Log.w(TAG, runnable + "not posted since looper is exiting");
+            }
+            return sent;
+        }
+
+        private void logIfExceedsThreshold(
+                long thresholdMillis, long startTimeMillis, NamedRunnable runnable,
+                String message) {
+            long elapsedMillis = SystemClock.elapsedRealtime() - startTimeMillis;
+            if (elapsedMillis > thresholdMillis) {
+                String elapsedFormatted =
+                        new SimpleDateFormat("mm:ss.SSS", Locale.US).format(elapsedMillis);
+                Log.w(TAG, runnable + " " + message + " " + elapsedFormatted);
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/eventloop/NamedRunnable.java b/nearby/service/java/com/android/server/nearby/common/eventloop/NamedRunnable.java
new file mode 100644
index 0000000..578e3f6
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/eventloop/NamedRunnable.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 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.common.eventloop;
+
+/** A Runnable with a name, for logging purposes. */
+public abstract class NamedRunnable implements Runnable {
+    public final String name;
+
+    public NamedRunnable(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public String toString() {
+        return "Runnable[" + name + "]";
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/fastpair/IconUtils.java b/nearby/service/java/com/android/server/nearby/common/fastpair/IconUtils.java
new file mode 100644
index 0000000..35a1a9f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/fastpair/IconUtils.java
@@ -0,0 +1,113 @@
+/*
+ * 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.common.fastpair;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.core.graphics.ColorUtils;
+
+/** Utility methods for icon size verification. */
+public class IconUtils {
+    private static final int MIN_ICON_SIZE = 16;
+    private static final int DESIRED_ICON_SIZE = 32;
+    private static final double NOTIFICATION_BACKGROUND_PADDING_PERCENTAGE = 0.125;
+    private static final double NOTIFICATION_BACKGROUND_ALPHA = 0.7;
+
+    /**
+     * Verify that the icon is non null and falls in the small bucket. Just because an icon isn't
+     * small doesn't guarantee it is large or exists.
+     */
+    @VisibleForTesting
+    static boolean isIconSizedSmall(@Nullable Bitmap bitmap) {
+        if (bitmap == null) {
+            return false;
+        }
+        int min = MIN_ICON_SIZE;
+        int desired = DESIRED_ICON_SIZE;
+        return bitmap.getWidth() >= min
+                && bitmap.getWidth() < desired
+                && bitmap.getHeight() >= min
+                && bitmap.getHeight() < desired;
+    }
+
+    /**
+     * Verify that the icon is non null and falls in the regular / default size bucket. Doesn't
+     * guarantee if not regular then it is small.
+     */
+    @VisibleForTesting
+    static boolean isIconSizedRegular(@Nullable Bitmap bitmap) {
+        if (bitmap == null) {
+            return false;
+        }
+        return bitmap.getWidth() >= DESIRED_ICON_SIZE
+                && bitmap.getHeight() >= DESIRED_ICON_SIZE;
+    }
+
+    // All icons that are sized correctly (larger than the min icon size) are resize on the server
+    // to the desired icon size so that they appear correct in notifications.
+
+    /**
+     * All icons that are sized correctly (larger than the min icon size) are resize on the server
+     * to the desired icon size so that they appear correct in notifications.
+     */
+    public static boolean isIconSizeCorrect(@Nullable Bitmap bitmap) {
+        if (bitmap == null) {
+            return false;
+        }
+        return isIconSizedSmall(bitmap) || isIconSizedRegular(bitmap);
+    }
+
+    /** Adds a circular, white background to the bitmap. */
+    @Nullable
+    public static Bitmap addWhiteCircleBackground(Context context, @Nullable Bitmap bitmap) {
+        if (bitmap == null) {
+            return null;
+        }
+
+        if (bitmap.getWidth() != bitmap.getHeight()) {
+            return bitmap;
+        }
+
+        int padding = (int) (bitmap.getWidth() * NOTIFICATION_BACKGROUND_PADDING_PERCENTAGE);
+        Bitmap bitmapWithBackground =
+                Bitmap.createBitmap(
+                        bitmap.getWidth() + (2 * padding),
+                        bitmap.getHeight() + (2 * padding),
+                        bitmap.getConfig());
+        Canvas canvas = new Canvas(bitmapWithBackground);
+        Paint paint = new Paint();
+        paint.setColor(
+                ColorUtils.setAlphaComponent(
+                        Color.WHITE, (int) (255 * NOTIFICATION_BACKGROUND_ALPHA)));
+        paint.setStyle(Paint.Style.FILL);
+        paint.setAntiAlias(true);
+        canvas.drawCircle(
+                bitmapWithBackground.getWidth() / 2f,
+                bitmapWithBackground.getHeight() / 2f,
+                bitmapWithBackground.getWidth() / 2f,
+                paint);
+        canvas.drawBitmap(bitmap, padding, padding, null);
+        return bitmapWithBackground;
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/fastpair/service/UserActionHandlerBase.java b/nearby/service/java/com/android/server/nearby/common/fastpair/service/UserActionHandlerBase.java
new file mode 100644
index 0000000..67d87e3
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/fastpair/service/UserActionHandlerBase.java
@@ -0,0 +1,29 @@
+/*
+ * 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.common.fastpair.service;
+
+/** Handles intents to {@link com.android.server.nearby.fastpair.FastPairManager}. */
+public class UserActionHandlerBase {
+    public static final String PREFIX = "com.android.server.nearby.fastpair.";
+    public static final String ACTION_PREFIX = "com.android.server.nearby:";
+
+    public static final String EXTRA_ITEM_ID = PREFIX + "EXTRA_ITEM_ID";
+    public static final String EXTRA_COMPANION_APP = ACTION_PREFIX + "EXTRA_COMPANION_APP";
+    public static final String EXTRA_MAC_ADDRESS = PREFIX + "EXTRA_MAC_ADDRESS";
+
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/locator/Locator.java b/nearby/service/java/com/android/server/nearby/common/locator/Locator.java
new file mode 100644
index 0000000..f8b43a6
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/locator/Locator.java
@@ -0,0 +1,298 @@
+/*
+ * 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.common.locator;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Collection of bindings that map service types to their respective implementation(s). */
+public class Locator {
+    private static final Object UNBOUND = new Object();
+    private final Context mContext;
+    @Nullable
+    private Locator mParent;
+    private final String mTag; // For debugging
+    private final Map<Class<?>, Object> mBindings = new HashMap<>();
+    private final ArrayList<Module> mModules = new ArrayList<>();
+
+    /** Thrown upon attempt to bind an interface twice. */
+    public static class DuplicateBindingException extends RuntimeException {
+        DuplicateBindingException(String msg) {
+            super(msg);
+        }
+    }
+
+    /** Constructor with a null parent. */
+    public Locator(Context context) {
+        this(context, null);
+    }
+
+    /**
+     * Constructor. Supply a valid context and the Locator's parent.
+     *
+     * <p>To find a suitable parent you may want to use findLocator.
+     */
+    public Locator(Context context, @Nullable Locator parent) {
+        this.mContext = context;
+        this.mParent = parent;
+        this.mTag = context.getClass().getName();
+    }
+
+    /** Attaches the parent to the locator. */
+    public void attachParent(Locator parent) {
+        this.mParent = parent;
+    }
+
+    /** Associates the specified type with the supplied instance. */
+    public <T extends Object> Locator bind(Class<T> type, T instance) {
+        bindKeyValue(type, instance);
+        return this;
+    }
+
+    /** For tests only. Disassociates the specified type from any instance. */
+    @VisibleForTesting
+    public <T extends Object> Locator overrideBindingForTest(Class<T> type, T instance) {
+        mBindings.remove(type);
+        return bind(type, instance);
+    }
+
+    /** For tests only. Force Locator to return null when try to get an instance. */
+    @VisibleForTesting
+    public <T> Locator removeBindingForTest(Class<T> type) {
+        Locator locator = this;
+        do {
+            locator.mBindings.put(type, UNBOUND);
+            locator = locator.mParent;
+        } while (locator != null);
+        return this;
+    }
+
+    /** Binds a module. */
+    public synchronized Locator bind(Module module) {
+        mModules.add(module);
+        return this;
+    }
+
+    /**
+     * Searches the chain of locators for a binding for the given type.
+     *
+     * @throws IllegalStateException if no binding is found.
+     */
+    public <T> T get(Class<T> type) {
+        T instance = getOptional(type);
+        if (instance != null) {
+            return instance;
+        }
+
+        String errorMessage = getUnboundErrorMessage(type);
+        throw new IllegalStateException(errorMessage);
+    }
+
+    private String getUnboundErrorMessage(Class<?> type) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("Unbound type: ").append(type.getName()).append("\n").append(
+                "Searched locators:\n");
+        Locator locator = this;
+        while (true) {
+            sb.append(locator.mTag);
+            locator = locator.mParent;
+            if (locator == null) {
+                break;
+            }
+            sb.append(" ->\n");
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Searches the chain of locators for a binding for the given type. Returns null if no locator
+     * was
+     * found.
+     */
+    @Nullable
+    public <T> T getOptional(Class<T> type) {
+        Locator locator = this;
+        do {
+            T instance = locator.getInstance(type);
+            if (instance != null) {
+                return instance;
+            }
+            locator = locator.mParent;
+        } while (locator != null);
+        return null;
+    }
+
+    private synchronized <T extends Object> void bindKeyValue(Class<T> key, T value) {
+        Object boundInstance = mBindings.get(key);
+        if (boundInstance != null) {
+            if (boundInstance == UNBOUND) {
+                Log.w(mTag, "Bind call too late - someone already tried to get: " + key);
+            } else {
+                throw new DuplicateBindingException("Duplicate binding: " + key);
+            }
+        }
+        mBindings.put(key, value);
+    }
+
+    // Suppress warning of cast from Object -> T
+    @SuppressWarnings("unchecked")
+    @Nullable
+    private synchronized <T> T getInstance(Class<T> type) {
+        if (mContext == null) {
+            throw new IllegalStateException("Locator not initialized yet.");
+        }
+
+        T instance = (T) mBindings.get(type);
+        if (instance != null) {
+            return instance != UNBOUND ? instance : null;
+        }
+
+        // Ask modules to supply a binding
+        int moduleCount = mModules.size();
+        for (int i = 0; i < moduleCount; i++) {
+            mModules.get(i).configure(mContext, type, this);
+        }
+
+        instance = (T) mBindings.get(type);
+        if (instance == null) {
+            mBindings.put(type, UNBOUND);
+        }
+        return instance;
+    }
+
+    /**
+     * Iterates over all bound objects and gives the modules a chance to clean up the objects they
+     * have created.
+     */
+    public synchronized void destroy() {
+        for (Class<?> type : mBindings.keySet()) {
+            Object instance = mBindings.get(type);
+            if (instance == UNBOUND) {
+                continue;
+            }
+
+            for (Module module : mModules) {
+                module.destroy(mContext, type, instance);
+            }
+        }
+        mBindings.clear();
+    }
+
+    /** Returns true if there are no bindings. */
+    public boolean isEmpty() {
+        return mBindings.isEmpty();
+    }
+
+    /** Returns the parent locator or null if no parent. */
+    @Nullable
+    public Locator getParent() {
+        return mParent;
+    }
+
+    /**
+     * Finds the first locator, then searches the chain of locators for a binding for the given
+     * type.
+     *
+     * @throws IllegalStateException if no binding is found.
+     */
+    public static <T> T get(Context context, Class<T> type) {
+        Locator locator = findLocator(context);
+        if (locator == null) {
+            throw new IllegalStateException("No locator found in context " + context);
+        }
+        return locator.get(type);
+    }
+
+    /**
+     * Find the first locator from the context wrapper.
+     */
+    public static <T> T getFromContextWrapper(LocatorContextWrapper wrapper, Class<T> type) {
+        Locator locator = wrapper.getLocator();
+        if (locator == null) {
+            throw new IllegalStateException("No locator found in context wrapper");
+        }
+        return locator.get(type);
+    }
+
+    /**
+     * Finds the first locator, then searches the chain of locators for a binding for the given
+     * type.
+     * Returns null if no binding was found.
+     */
+    @Nullable
+    public static <T> T getOptional(Context context, Class<T> type) {
+        Locator locator = findLocator(context);
+        if (locator == null) {
+            return null;
+        }
+        return locator.getOptional(type);
+    }
+
+    /** Finds the first locator in the context hierarchy. */
+    @Nullable
+    public static Locator findLocator(Context context) {
+        Context applicationContext = context.getApplicationContext();
+        boolean applicationContextVisited = false;
+
+        Context searchContext = context;
+        do {
+            Locator locator = tryGetLocator(searchContext);
+            if (locator != null) {
+                return locator;
+            }
+
+            applicationContextVisited |= (searchContext == applicationContext);
+
+            if (searchContext instanceof ContextWrapper) {
+                searchContext = ((ContextWrapper) context).getBaseContext();
+
+                if (searchContext == null) {
+                    throw new IllegalStateException(
+                            "Invalid ContextWrapper -- If this is a Robolectric test, "
+                                    + "have you called ActivityController.create()?");
+                }
+            } else if (!applicationContextVisited) {
+                searchContext = applicationContext;
+            } else {
+                searchContext = null;
+            }
+        } while (searchContext != null);
+
+        return null;
+    }
+
+    @Nullable
+    private static Locator tryGetLocator(Object object) {
+        if (object instanceof LocatorContext) {
+            Locator locator = ((LocatorContext) object).getLocator();
+            if (locator == null) {
+                throw new IllegalStateException(
+                        "LocatorContext must not return null Locator: " + object);
+            }
+            return locator;
+        }
+        return null;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/locator/LocatorContext.java b/nearby/service/java/com/android/server/nearby/common/locator/LocatorContext.java
new file mode 100644
index 0000000..06eef8a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/locator/LocatorContext.java
@@ -0,0 +1,26 @@
+/*
+ * 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.common.locator;
+
+/**
+ * An object that has a {@link Locator}. The locator can be used to resolve service types to their
+ * respective implementation(s).
+ */
+public interface LocatorContext {
+    /** Returns the locator. May not return null. */
+    Locator getLocator();
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/locator/LocatorContextWrapper.java b/nearby/service/java/com/android/server/nearby/common/locator/LocatorContextWrapper.java
new file mode 100644
index 0000000..03df33f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/locator/LocatorContextWrapper.java
@@ -0,0 +1,57 @@
+/*
+ * 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.common.locator;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.ContextWrapper;
+
+/**
+ * Wraps a Context and associates it with a Locator, optionally linking it with a parent locator.
+ */
+public class LocatorContextWrapper extends ContextWrapper implements LocatorContext {
+    private final Locator mLocator;
+    private final Context mContext;
+    /** Constructs a context wrapper with a Locator linked to the passed locator. */
+    public LocatorContextWrapper(Context context, @Nullable Locator parentLocator) {
+        super(context);
+        mContext = context;
+        // Assigning under initialization object, but it's safe, since locator is used lazily.
+        this.mLocator = new Locator(this, parentLocator);
+    }
+
+    /**
+     * Constructs a context wrapper.
+     *
+     * <p>Uses the Locator associated with the passed context as the parent.
+     */
+    public LocatorContextWrapper(Context context) {
+        this(context, Locator.findLocator(context));
+    }
+
+    /**
+     * Get the context of the context wrapper.
+     */
+    public Context getContext() {
+        return mContext;
+    }
+
+    @Override
+    public Locator getLocator() {
+        return mLocator;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/locator/Module.java b/nearby/service/java/com/android/server/nearby/common/locator/Module.java
new file mode 100644
index 0000000..0131c44
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/locator/Module.java
@@ -0,0 +1,57 @@
+/*
+ * 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.common.locator;
+
+import android.content.Context;
+
+/** Configures late bindings of service types to their concrete implementations. */
+public abstract class Module {
+    /**
+     * Configures the binding between the {@code type} and its implementation by calling methods on
+     * the {@code locator}, for example:
+     *
+     * <pre>{@code
+     * void configure(Context context, Class<?> type, Locator locator) {
+     *   if (type == MyService.class) {
+     *     locator.bind(MyService.class, new MyImplementation(context));
+     *   }
+     * }
+     * }</pre>
+     *
+     * <p>If the module does not recognize the specified type, the method does not have to do
+     * anything.
+     */
+    public abstract void configure(Context context, Class<?> type, Locator locator);
+
+    /**
+     * Notifies you that a binding of class {@code type} is no longer needed and can now release
+     * everything it was holding on to, such as a database connection.
+     *
+     * <pre>{@code
+     * void destroy(Context context, Class<?> type, Object instance) {
+     *   if (type == MyService.class) {
+     *     ((MyService) instance).destroy();
+     *   }
+     * }
+     * }</pre>
+     *
+     * <p>If the module does not recognize the specified type, the method does not have to do
+     * anything.
+     */
+    public void destroy(Context context, Class<?> type, Object instance) {}
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/common/servicemonitor/CurrentUserServiceProvider.java b/nearby/service/java/com/android/server/nearby/common/servicemonitor/CurrentUserServiceProvider.java
new file mode 100644
index 0000000..80248e8
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/servicemonitor/CurrentUserServiceProvider.java
@@ -0,0 +1,217 @@
+/*
+ * 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.common.servicemonitor;
+
+import static android.content.pm.PackageManager.GET_META_DATA;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AUTO;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE;
+import static android.content.pm.PackageManager.MATCH_SYSTEM_ONLY;
+
+import android.app.ActivityManager;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ResolveInfo;
+import android.os.UserHandle;
+
+import com.android.internal.util.Preconditions;
+import com.android.server.nearby.common.servicemonitor.ServiceMonitor.ServiceChangedListener;
+import com.android.server.nearby.common.servicemonitor.ServiceMonitor.ServiceProvider;
+
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * This is mostly borrowed from frameworks CurrentUserServiceSupplier.
+ * Provides services based on the current active user and version as defined in the service
+ * manifest. This implementation uses {@link android.content.pm.PackageManager#MATCH_SYSTEM_ONLY} to
+ * ensure only system (ie, privileged) services are matched. It also handles services that are not
+ * direct boot aware, and will automatically pick the best service as the user's direct boot state
+ * changes.
+ */
+public final class CurrentUserServiceProvider extends BroadcastReceiver implements
+        ServiceProvider<CurrentUserServiceProvider.BoundServiceInfo> {
+
+    private static final String TAG = "CurrentUserServiceProvider";
+
+    private static final String EXTRA_SERVICE_VERSION = "serviceVersion";
+
+    // This is equal to the hidden Intent.ACTION_USER_SWITCHED.
+    private static final String ACTION_USER_SWITCHED = "android.intent.action.USER_SWITCHED";
+    // This is equal to the hidden Intent.EXTRA_USER_HANDLE.
+    private static final String EXTRA_USER_HANDLE = "android.intent.extra.user_handle";
+    // This is equal to the hidden UserHandle.USER_NULL.
+    private static final int USER_NULL = -10000;
+
+    private static final Comparator<BoundServiceInfo> sBoundServiceInfoComparator = (o1, o2) -> {
+        if (o1 == o2) {
+            return 0;
+        } else if (o1 == null) {
+            return -1;
+        } else if (o2 == null) {
+            return 1;
+        }
+
+        // ServiceInfos with higher version numbers always win.
+        return Integer.compare(o1.getVersion(), o2.getVersion());
+    };
+
+    /** Bound service information with version information. */
+    public static class BoundServiceInfo extends ServiceMonitor.BoundServiceInfo {
+
+        private static int parseUid(ResolveInfo resolveInfo) {
+            return resolveInfo.serviceInfo.applicationInfo.uid;
+        }
+
+        private static int parseVersion(ResolveInfo resolveInfo) {
+            int version = Integer.MIN_VALUE;
+            if (resolveInfo.serviceInfo.metaData != null) {
+                version = resolveInfo.serviceInfo.metaData.getInt(EXTRA_SERVICE_VERSION, version);
+            }
+            return version;
+        }
+
+        private final int mVersion;
+
+        protected BoundServiceInfo(String action, ResolveInfo resolveInfo) {
+            this(
+                    action,
+                    parseUid(resolveInfo),
+                    new ComponentName(
+                            resolveInfo.serviceInfo.packageName,
+                            resolveInfo.serviceInfo.name),
+                    parseVersion(resolveInfo));
+        }
+
+        protected BoundServiceInfo(String action, int uid, ComponentName componentName,
+                int version) {
+            super(action, uid, componentName);
+            mVersion = version;
+        }
+
+        public int getVersion() {
+            return mVersion;
+        }
+
+        @Override
+        public String toString() {
+            return super.toString() + "@" + mVersion;
+        }
+    }
+
+    /**
+     * Creates an instance with the specific service details.
+     *
+     * @param context the context the provider is to use
+     * @param action the action the service must declare in its intent-filter
+     */
+    public static CurrentUserServiceProvider create(Context context, String action) {
+        return new CurrentUserServiceProvider(context, action);
+    }
+
+    private final Context mContext;
+    private final Intent mIntent;
+    private volatile ServiceChangedListener mListener;
+
+    private CurrentUserServiceProvider(Context context, String action) {
+        mContext = context;
+        mIntent = new Intent(action);
+    }
+
+    @Override
+    public boolean hasMatchingService() {
+        int intentQueryFlags =
+                MATCH_DIRECT_BOOT_AWARE | MATCH_DIRECT_BOOT_UNAWARE | MATCH_SYSTEM_ONLY;
+        List<ResolveInfo> resolveInfos = mContext.getPackageManager().queryIntentServicesAsUser(
+                mIntent, intentQueryFlags, UserHandle.SYSTEM);
+        return !resolveInfos.isEmpty();
+    }
+
+    @Override
+    public void register(ServiceChangedListener listener) {
+        Preconditions.checkState(mListener == null);
+
+        mListener = listener;
+
+        IntentFilter intentFilter = new IntentFilter();
+        intentFilter.addAction(ACTION_USER_SWITCHED);
+        intentFilter.addAction(Intent.ACTION_USER_UNLOCKED);
+        mContext.registerReceiverForAllUsers(this, intentFilter, null,
+                ForegroundThread.getHandler());
+    }
+
+    @Override
+    public void unregister() {
+        Preconditions.checkArgument(mListener != null);
+
+        mListener = null;
+        mContext.unregisterReceiver(this);
+    }
+
+    @Override
+    public BoundServiceInfo getServiceInfo() {
+        BoundServiceInfo bestServiceInfo = null;
+
+        // only allow services in the correct direct boot state to match
+        int intentQueryFlags = MATCH_DIRECT_BOOT_AUTO | GET_META_DATA | MATCH_SYSTEM_ONLY;
+        List<ResolveInfo> resolveInfos = mContext.getPackageManager().queryIntentServicesAsUser(
+                mIntent, intentQueryFlags, UserHandle.of(ActivityManager.getCurrentUser()));
+        for (ResolveInfo resolveInfo : resolveInfos) {
+            BoundServiceInfo serviceInfo =
+                    new BoundServiceInfo(mIntent.getAction(), resolveInfo);
+
+            if (sBoundServiceInfoComparator.compare(serviceInfo, bestServiceInfo) > 0) {
+                bestServiceInfo = serviceInfo;
+            }
+        }
+
+        return bestServiceInfo;
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        String action = intent.getAction();
+        if (action == null) {
+            return;
+        }
+        int userId = intent.getIntExtra(EXTRA_USER_HANDLE, USER_NULL);
+        if (userId == USER_NULL) {
+            return;
+        }
+        ServiceChangedListener listener = mListener;
+        if (listener == null) {
+            return;
+        }
+
+        switch (action) {
+            case ACTION_USER_SWITCHED:
+                listener.onServiceChanged();
+                break;
+            case Intent.ACTION_USER_UNLOCKED:
+                // user unlocked implies direct boot mode may have changed
+                if (userId == ActivityManager.getCurrentUser()) {
+                    listener.onServiceChanged();
+                }
+                break;
+            default:
+                break;
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/servicemonitor/ForegroundThread.java b/nearby/service/java/com/android/server/nearby/common/servicemonitor/ForegroundThread.java
new file mode 100644
index 0000000..2c363f8
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/servicemonitor/ForegroundThread.java
@@ -0,0 +1,77 @@
+/*
+ * 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.common.servicemonitor;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import com.android.modules.utils.HandlerExecutor;
+
+import java.util.concurrent.Executor;
+
+/**
+ * Thread for asynchronous event processing. This thread is configured as
+ * {@link android.os.Process#THREAD_PRIORITY_FOREGROUND}, which means more CPU
+ * resources will be dedicated to it, and it will be treated like "a user
+ * interface that the user is interacting with."
+ * <p>
+ * This thread is best suited for tasks that the user is actively waiting for,
+ * or for tasks that the user expects to be executed immediately.
+ *
+ */
+public final class ForegroundThread extends HandlerThread {
+    private static ForegroundThread sInstance;
+    private static Handler sHandler;
+    private static HandlerExecutor sHandlerExecutor;
+
+    private ForegroundThread() {
+        super("nearbyfg", android.os.Process.THREAD_PRIORITY_FOREGROUND);
+    }
+
+    private static void ensureThreadLocked() {
+        if (sInstance == null) {
+            sInstance = new ForegroundThread();
+            sInstance.start();
+            sHandler = new Handler(sInstance.getLooper());
+            sHandlerExecutor = new HandlerExecutor(sHandler);
+        }
+    }
+
+    /** Get ForegroundThread singleton instance. */
+    public static ForegroundThread get() {
+        synchronized (ForegroundThread.class) {
+            ensureThreadLocked();
+            return sInstance;
+        }
+    }
+
+    /** Get ForegroundThread singleton handler. */
+    public static Handler getHandler() {
+        synchronized (ForegroundThread.class) {
+            ensureThreadLocked();
+            return sHandler;
+        }
+    }
+
+    /** Get ForegroundThread singleton executor. */
+    public static Executor getExecutor() {
+        synchronized (ForegroundThread.class) {
+            ensureThreadLocked();
+            return sHandlerExecutor;
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/servicemonitor/PackageWatcher.java b/nearby/service/java/com/android/server/nearby/common/servicemonitor/PackageWatcher.java
new file mode 100644
index 0000000..7d1db57
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/servicemonitor/PackageWatcher.java
@@ -0,0 +1,130 @@
+/*
+ * 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.common.servicemonitor;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+
+import com.android.modules.utils.BackgroundThread;
+
+import java.util.Objects;
+
+/**
+ * This is mostly from frameworks PackageMonitor.
+ * Helper class for watching somePackagesChanged.
+ */
+public abstract class PackageWatcher extends BroadcastReceiver {
+    static final String TAG = "PackageWatcher";
+    static final IntentFilter sPackageFilt = new IntentFilter();
+    static final IntentFilter sNonDataFilt = new IntentFilter();
+    static final IntentFilter sExternalFilt = new IntentFilter();
+
+    static {
+        sPackageFilt.addAction(Intent.ACTION_PACKAGE_ADDED);
+        sPackageFilt.addAction(Intent.ACTION_PACKAGE_REMOVED);
+        sPackageFilt.addAction(Intent.ACTION_PACKAGE_CHANGED);
+        sPackageFilt.addDataScheme("package");
+        sNonDataFilt.addAction(Intent.ACTION_PACKAGES_SUSPENDED);
+        sNonDataFilt.addAction(Intent.ACTION_PACKAGES_UNSUSPENDED);
+        sExternalFilt.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE);
+        sExternalFilt.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_UNAVAILABLE);
+    }
+
+    Context mRegisteredContext;
+    Handler mRegisteredHandler;
+    boolean mSomePackagesChanged;
+
+    public PackageWatcher() {
+    }
+
+    void register(Context context, Looper thread, boolean externalStorage) {
+        register(context, externalStorage,
+                (thread == null) ? BackgroundThread.getHandler() : new Handler(thread));
+    }
+
+    void register(Context context, boolean externalStorage, Handler handler) {
+        if (mRegisteredContext != null) {
+            throw new IllegalStateException("Already registered");
+        }
+        mRegisteredContext = context;
+        mRegisteredHandler = Objects.requireNonNull(handler);
+        context.registerReceiverForAllUsers(this, sPackageFilt, null, mRegisteredHandler);
+        context.registerReceiverForAllUsers(this, sNonDataFilt, null, mRegisteredHandler);
+        if (externalStorage) {
+            context.registerReceiverForAllUsers(this, sExternalFilt, null, mRegisteredHandler);
+        }
+    }
+
+    void unregister() {
+        if (mRegisteredContext == null) {
+            throw new IllegalStateException("Not registered");
+        }
+        mRegisteredContext.unregisterReceiver(this);
+        mRegisteredContext = null;
+    }
+
+    // Called when some package has been changed.
+    abstract void onSomePackagesChanged();
+
+    String getPackageName(Intent intent) {
+        Uri uri = intent.getData();
+        String pkg = uri != null ? uri.getSchemeSpecificPart() : null;
+        return pkg;
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        mSomePackagesChanged = false;
+
+        String action = intent.getAction();
+        if (Intent.ACTION_PACKAGE_ADDED.equals(action)) {
+            // We consider something to have changed regardless of whether
+            // this is just an update, because the update is now finished
+            // and the contents of the package may have changed.
+            mSomePackagesChanged = true;
+        } else if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) {
+            String pkg = getPackageName(intent);
+            if (pkg != null) {
+                if (!intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) {
+                    mSomePackagesChanged = true;
+                }
+            }
+        } else if (Intent.ACTION_PACKAGE_CHANGED.equals(action)) {
+            String pkg = getPackageName(intent);
+            if (pkg != null) {
+                mSomePackagesChanged = true;
+            }
+        } else if (Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE.equals(action)) {
+            mSomePackagesChanged = true;
+        } else if (Intent.ACTION_EXTERNAL_APPLICATIONS_UNAVAILABLE.equals(action)) {
+            mSomePackagesChanged = true;
+        } else if (Intent.ACTION_PACKAGES_SUSPENDED.equals(action)) {
+            mSomePackagesChanged = true;
+        } else if (Intent.ACTION_PACKAGES_UNSUSPENDED.equals(action)) {
+            mSomePackagesChanged = true;
+        }
+
+        if (mSomePackagesChanged) {
+            onSomePackagesChanged();
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/servicemonitor/ServiceMonitor.java b/nearby/service/java/com/android/server/nearby/common/servicemonitor/ServiceMonitor.java
new file mode 100644
index 0000000..a86af85
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/servicemonitor/ServiceMonitor.java
@@ -0,0 +1,249 @@
+/*
+ * 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.common.servicemonitor;
+
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.RemoteException;
+
+import java.io.PrintWriter;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+
+/**
+ * This is exported from frameworks ServiceWatcher.
+ * A ServiceMonitor is responsible for continuously maintaining an active binding to a service
+ * selected by it's {@link ServiceProvider}. The {@link ServiceProvider} may change the service it
+ * selects over time, and the currently bound service may crash, restart, have a user change, have
+ * changes made to its package, and so on and so forth. The ServiceMonitor is responsible for
+ * maintaining the binding across all these changes.
+ *
+ * <p>Clients may invoke {@link BinderOperation}s on the ServiceMonitor, and it will make a best
+ * effort to run these on the currently bound service, but individual operations may fail (if there
+ * is no service currently bound for instance). In order to help clients maintain the correct state,
+ * clients may supply a {@link ServiceListener}, which is informed when the ServiceMonitor connects
+ * and disconnects from a service. This allows clients to bring a bound service back into a known
+ * state on connection, and then run binder operations from there. In order to help clients
+ * accomplish this, ServiceMonitor guarantees that {@link BinderOperation}s and the
+ * {@link ServiceListener} will always be run on the same thread, so that strong ordering guarantees
+ * can be established between them.
+ *
+ * There is never any guarantee of whether a ServiceMonitor is currently connected to a service, and
+ * whether any particular {@link BinderOperation} will succeed. Clients must ensure they do not rely
+ * on this, and instead use {@link ServiceListener} notifications as necessary to recover from
+ * failures.
+ */
+public interface ServiceMonitor {
+
+    /**
+     * Operation to run on a binder interface. All operations will be run on the thread used by the
+     * ServiceMonitor this is run with.
+     */
+    interface BinderOperation {
+        /** Invoked to run the operation. Run on the ServiceMonitor thread. */
+        void run(IBinder binder) throws RemoteException;
+
+        /**
+         * Invoked if {@link #run(IBinder)} could not be invoked because there was no current
+         * binding, or if {@link #run(IBinder)} threw an exception ({@link RemoteException} or
+         * {@link RuntimeException}). This callback is only intended for resource deallocation and
+         * cleanup in response to a single binder operation, it should not be used to propagate
+         * errors further. Run on the ServiceMonitor thread.
+         */
+        default void onError() {}
+    }
+
+    /**
+     * Listener for bind and unbind events. All operations will be run on the thread used by the
+     * ServiceMonitor this is run with.
+     *
+     * @param <TBoundServiceInfo> type of bound service
+     */
+    interface ServiceListener<TBoundServiceInfo extends BoundServiceInfo> {
+        /** Invoked when a service is bound. Run on the ServiceMonitor thread. */
+        void onBind(IBinder binder, TBoundServiceInfo service) throws RemoteException;
+
+        /** Invoked when a service is unbound. Run on the ServiceMonitor thread. */
+        void onUnbind();
+    }
+
+    /**
+     * A listener for when a {@link ServiceProvider} decides that the current service has changed.
+     */
+    interface ServiceChangedListener {
+        /**
+         * Should be invoked when the current service may have changed.
+         */
+        void onServiceChanged();
+    }
+
+    /**
+     * This provider encapsulates the logic of deciding what service a {@link ServiceMonitor} should
+     * be bound to at any given moment.
+     *
+     * @param <TBoundServiceInfo> type of bound service
+     */
+    interface ServiceProvider<TBoundServiceInfo extends BoundServiceInfo> {
+        /**
+         * Should return true if there exists at least one service capable of meeting the criteria
+         * of this provider. This does not imply that {@link #getServiceInfo()} will always return a
+         * non-null result, as any service may be disqualified for various reasons at any point in
+         * time. May be invoked at any time from any thread and thus should generally not have any
+         * dependency on the other methods in this interface.
+         */
+        boolean hasMatchingService();
+
+        /**
+         * Invoked when the provider should start monitoring for any changes that could result in a
+         * different service selection, and should invoke
+         * {@link ServiceChangedListener#onServiceChanged()} in that case. {@link #getServiceInfo()}
+         * may be invoked after this method is called.
+         */
+        void register(ServiceChangedListener listener);
+
+        /**
+         * Invoked when the provider should stop monitoring for any changes that could result in a
+         * different service selection, should no longer invoke
+         * {@link ServiceChangedListener#onServiceChanged()}. {@link #getServiceInfo()} will not be
+         * invoked after this method is called.
+         */
+        void unregister();
+
+        /**
+         * Must be implemented to return the current service selected by this provider. May return
+         * null if no service currently meets the criteria. Only invoked while registered.
+         */
+        @Nullable TBoundServiceInfo getServiceInfo();
+    }
+
+    /**
+     * Information on the service selected as the best option for binding.
+     */
+    class BoundServiceInfo {
+
+        protected final @Nullable String mAction;
+        protected final int mUid;
+        protected final ComponentName mComponentName;
+
+        protected BoundServiceInfo(String action, int uid, ComponentName componentName) {
+            mAction = action;
+            mUid = uid;
+            mComponentName = Objects.requireNonNull(componentName);
+        }
+
+        /** Returns the action associated with this bound service. */
+        public @Nullable String getAction() {
+            return mAction;
+        }
+
+        /** Returns the component of this bound service. */
+        public ComponentName getComponentName() {
+            return mComponentName;
+        }
+
+        @Override
+        public final boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (!(o instanceof BoundServiceInfo)) {
+                return false;
+            }
+
+            BoundServiceInfo that = (BoundServiceInfo) o;
+            return mUid == that.mUid
+                    && Objects.equals(mAction, that.mAction)
+                    && mComponentName.equals(that.mComponentName);
+        }
+
+        @Override
+        public final int hashCode() {
+            return Objects.hash(mAction, mUid, mComponentName);
+        }
+
+        @Override
+        public String toString() {
+            if (mComponentName == null) {
+                return "none";
+            } else {
+                return mUid + "/" + mComponentName.flattenToShortString();
+            }
+        }
+    }
+
+    /**
+     * Creates a new ServiceMonitor instance.
+     */
+    static <TBoundServiceInfo extends BoundServiceInfo> ServiceMonitor create(
+            Context context,
+            String tag,
+            ServiceProvider<TBoundServiceInfo> serviceProvider,
+            @Nullable ServiceListener<? super TBoundServiceInfo> serviceListener) {
+        return create(context, ForegroundThread.getHandler(), ForegroundThread.getExecutor(), tag,
+                serviceProvider, serviceListener);
+    }
+
+    /**
+     * Creates a new ServiceMonitor instance that runs on the given handler.
+     */
+    static <TBoundServiceInfo extends BoundServiceInfo> ServiceMonitor create(
+            Context context,
+            Handler handler,
+            Executor executor,
+            String tag,
+            ServiceProvider<TBoundServiceInfo> serviceProvider,
+            @Nullable ServiceListener<? super TBoundServiceInfo> serviceListener) {
+        return new ServiceMonitorImpl<>(context, handler, executor, tag, serviceProvider,
+                serviceListener);
+    }
+
+    /**
+     * Returns true if there is at least one service that the ServiceMonitor could hypothetically
+     * bind to, as selected by the {@link ServiceProvider}.
+     */
+    boolean checkServiceResolves();
+
+    /**
+     * Registers the ServiceMonitor, so that it will begin maintaining an active binding to the
+     * service selected by {@link ServiceProvider}, until {@link #unregister()} is called.
+     */
+    void register();
+
+    /**
+     * Unregisters the ServiceMonitor, so that it will release any active bindings. If the
+     * ServiceMonitor is currently bound, this will result in one final
+     * {@link ServiceListener#onUnbind()} invocation, which may happen after this method completes
+     * (but which is guaranteed to occur before any further
+     * {@link ServiceListener#onBind(IBinder, BoundServiceInfo)} invocation in response to a later
+     * call to {@link #register()}).
+     */
+    void unregister();
+
+    /**
+     * Runs the given binder operation on the currently bound service (if available). The operation
+     * will always fail if the ServiceMonitor is not currently registered.
+     */
+    void runOnBinder(BinderOperation operation);
+
+    /**
+     * Dumps ServiceMonitor information.
+     */
+    void dump(PrintWriter pw);
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/servicemonitor/ServiceMonitorImpl.java b/nearby/service/java/com/android/server/nearby/common/servicemonitor/ServiceMonitorImpl.java
new file mode 100644
index 0000000..d0d6c3b
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/servicemonitor/ServiceMonitorImpl.java
@@ -0,0 +1,305 @@
+/*
+ * 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.common.servicemonitor;
+
+import static android.content.Context.BIND_AUTO_CREATE;
+import static android.content.Context.BIND_NOT_FOREGROUND;
+
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.Preconditions;
+import com.android.server.nearby.common.servicemonitor.ServiceMonitor.BoundServiceInfo;
+import com.android.server.nearby.common.servicemonitor.ServiceMonitor.ServiceChangedListener;
+
+import java.io.PrintWriter;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+
+/**
+ * Implementation of ServiceMonitor. Keeping the implementation separate from the interface allows
+ * us to store the generic relationship between the service provider and the service listener, while
+ * hiding the generics from clients, simplifying the API.
+ */
+class ServiceMonitorImpl<TBoundServiceInfo extends BoundServiceInfo> implements ServiceMonitor,
+        ServiceChangedListener {
+
+    private static final String TAG = "ServiceMonitor";
+    private static final boolean D = Log.isLoggable(TAG, Log.DEBUG);
+    private static final long RETRY_DELAY_MS = 15 * 1000;
+
+    // This is the same as Context.BIND_NOT_VISIBLE.
+    private static final int BIND_NOT_VISIBLE = 0x40000000;
+
+    final Context mContext;
+    final Handler mHandler;
+    final Executor mExecutor;
+    final String mTag;
+    final ServiceProvider<TBoundServiceInfo> mServiceProvider;
+    final @Nullable ServiceListener<? super TBoundServiceInfo> mServiceListener;
+
+    private final PackageWatcher mPackageWatcher = new PackageWatcher() {
+        @Override
+        public void onSomePackagesChanged() {
+            onServiceChanged(false);
+        }
+    };
+
+    @GuardedBy("this")
+    private boolean mRegistered = false;
+    @GuardedBy("this")
+    private MyServiceConnection mServiceConnection = new MyServiceConnection(null);
+
+    ServiceMonitorImpl(Context context, Handler handler, Executor executor, String tag,
+            ServiceProvider<TBoundServiceInfo> serviceProvider,
+            ServiceListener<? super TBoundServiceInfo> serviceListener) {
+        mContext = context;
+        mExecutor = executor;
+        mHandler = handler;
+        mTag = tag;
+        mServiceProvider = serviceProvider;
+        mServiceListener = serviceListener;
+    }
+
+    @Override
+    public boolean checkServiceResolves() {
+        return mServiceProvider.hasMatchingService();
+    }
+
+    @Override
+    public synchronized void register() {
+        Preconditions.checkState(!mRegistered);
+
+        mRegistered = true;
+        mPackageWatcher.register(mContext, /*externalStorage=*/ true, mHandler);
+        mServiceProvider.register(this);
+
+        onServiceChanged(false);
+    }
+
+    @Override
+    public synchronized void unregister() {
+        Preconditions.checkState(mRegistered);
+
+        mServiceProvider.unregister();
+        mPackageWatcher.unregister();
+        mRegistered = false;
+
+        onServiceChanged(false);
+    }
+
+    @Override
+    public synchronized void onServiceChanged() {
+        onServiceChanged(false);
+    }
+
+    @Override
+    public synchronized void runOnBinder(BinderOperation operation) {
+        MyServiceConnection serviceConnection = mServiceConnection;
+        mHandler.post(() -> serviceConnection.runOnBinder(operation));
+    }
+
+    synchronized void onServiceChanged(boolean forceRebind) {
+        TBoundServiceInfo newBoundServiceInfo;
+        if (mRegistered) {
+            newBoundServiceInfo = mServiceProvider.getServiceInfo();
+        } else {
+            newBoundServiceInfo = null;
+        }
+
+        if (forceRebind || !Objects.equals(mServiceConnection.getBoundServiceInfo(),
+                newBoundServiceInfo)) {
+            Log.i(TAG, "[" + mTag + "] chose new implementation " + newBoundServiceInfo);
+            MyServiceConnection oldServiceConnection = mServiceConnection;
+            MyServiceConnection newServiceConnection = new MyServiceConnection(newBoundServiceInfo);
+            mServiceConnection = newServiceConnection;
+            mHandler.post(() -> {
+                oldServiceConnection.unbind();
+                newServiceConnection.bind();
+            });
+        }
+    }
+
+    @Override
+    public String toString() {
+        MyServiceConnection serviceConnection;
+        synchronized (this) {
+            serviceConnection = mServiceConnection;
+        }
+
+        return serviceConnection.getBoundServiceInfo().toString();
+    }
+
+    @Override
+    public void dump(PrintWriter pw) {
+        MyServiceConnection serviceConnection;
+        synchronized (this) {
+            serviceConnection = mServiceConnection;
+        }
+
+        pw.println("target service=" + serviceConnection.getBoundServiceInfo());
+        pw.println("connected=" + serviceConnection.isConnected());
+    }
+
+    // runs on the handler thread, and expects most of its methods to be called from that thread
+    private class MyServiceConnection implements ServiceConnection {
+
+        private final @Nullable TBoundServiceInfo mBoundServiceInfo;
+
+        // volatile so that isConnected can be called from any thread easily
+        private volatile @Nullable IBinder mBinder;
+        private @Nullable Runnable mRebinder;
+
+        MyServiceConnection(@Nullable TBoundServiceInfo boundServiceInfo) {
+            mBoundServiceInfo = boundServiceInfo;
+        }
+
+        // may be called from any thread
+        @Nullable TBoundServiceInfo getBoundServiceInfo() {
+            return mBoundServiceInfo;
+        }
+
+        // may be called from any thread
+        boolean isConnected() {
+            return mBinder != null;
+        }
+
+        void bind() {
+            Preconditions.checkState(Looper.myLooper() == mHandler.getLooper());
+
+            if (mBoundServiceInfo == null) {
+                return;
+            }
+
+            if (D) {
+                Log.d(TAG, "[" + mTag + "] binding to " + mBoundServiceInfo);
+            }
+
+            Intent bindIntent = new Intent(mBoundServiceInfo.getAction())
+                    .setComponent(mBoundServiceInfo.getComponentName());
+            if (!mContext.bindService(bindIntent,
+                    BIND_AUTO_CREATE | BIND_NOT_FOREGROUND | BIND_NOT_VISIBLE,
+                    mExecutor, this)) {
+                Log.e(TAG, "[" + mTag + "] unexpected bind failure - retrying later");
+                mRebinder = this::bind;
+                mHandler.postDelayed(mRebinder, RETRY_DELAY_MS);
+            } else {
+                mRebinder = null;
+            }
+        }
+
+        void unbind() {
+            Preconditions.checkState(Looper.myLooper() == mHandler.getLooper());
+
+            if (mBoundServiceInfo == null) {
+                return;
+            }
+
+            if (D) {
+                Log.d(TAG, "[" + mTag + "] unbinding from " + mBoundServiceInfo);
+            }
+
+            if (mRebinder != null) {
+                mHandler.removeCallbacks(mRebinder);
+                mRebinder = null;
+            } else {
+                mContext.unbindService(this);
+            }
+
+            onServiceDisconnected(mBoundServiceInfo.getComponentName());
+        }
+
+        void runOnBinder(BinderOperation operation) {
+            Preconditions.checkState(Looper.myLooper() == mHandler.getLooper());
+
+            if (mBinder == null) {
+                operation.onError();
+                return;
+            }
+
+            try {
+                operation.run(mBinder);
+            } catch (RuntimeException | RemoteException e) {
+                // binders may propagate some specific non-RemoteExceptions from the other side
+                // through the binder as well - we cannot allow those to crash the system server
+                Log.e(TAG, "[" + mTag + "] error running operation on " + mBoundServiceInfo, e);
+                operation.onError();
+            }
+        }
+
+        @Override
+        public final void onServiceConnected(ComponentName component, IBinder binder) {
+            Preconditions.checkState(Looper.myLooper() == mHandler.getLooper());
+            Preconditions.checkState(mBinder == null);
+
+            Log.i(TAG, "[" + mTag + "] connected to " + component.toShortString());
+
+            mBinder = binder;
+
+            if (mServiceListener != null) {
+                try {
+                    mServiceListener.onBind(binder, mBoundServiceInfo);
+                } catch (RuntimeException | RemoteException e) {
+                    // binders may propagate some specific non-RemoteExceptions from the other side
+                    // through the binder as well - we cannot allow those to crash the system server
+                    Log.e(TAG, "[" + mTag + "] error running operation on " + mBoundServiceInfo, e);
+                }
+            }
+        }
+
+        @Override
+        public final void onServiceDisconnected(ComponentName component) {
+            Preconditions.checkState(Looper.myLooper() == mHandler.getLooper());
+
+            if (mBinder == null) {
+                return;
+            }
+
+            Log.i(TAG, "[" + mTag + "] disconnected from " + mBoundServiceInfo);
+
+            mBinder = null;
+            if (mServiceListener != null) {
+                mServiceListener.onUnbind();
+            }
+        }
+
+        @Override
+        public final void onBindingDied(ComponentName component) {
+            Preconditions.checkState(Looper.myLooper() == mHandler.getLooper());
+
+            Log.w(TAG, "[" + mTag + "] " + mBoundServiceInfo + " died");
+
+            // introduce a small delay to prevent spamming binding over and over, since the likely
+            // cause of a binding dying is some package event that may take time to recover from
+            mHandler.postDelayed(() -> onServiceChanged(true), 500);
+        }
+
+        @Override
+        public final void onNullBinding(ComponentName component) {
+            Log.e(TAG, "[" + mTag + "] " + mBoundServiceInfo + " has null binding");
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/Constant.java b/nearby/service/java/com/android/server/nearby/fastpair/Constant.java
new file mode 100644
index 0000000..0695b5f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/Constant.java
@@ -0,0 +1,43 @@
+/*
+ * 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.fastpair;
+
+/**
+ * String constant for half sheet.
+ */
+public class Constant {
+
+    /**
+     * Value represents true for {@link android.provider.Settings.Secure}
+     */
+    public static final int SETTINGS_TRUE_VALUE = 1;
+
+    /**
+     * Tag for Fast Pair service related logs.
+     */
+    public static final String TAG = "FastPairService";
+
+    public static final String EXTRA_BINDER = "com.android.server.nearby.fastpair.BINDER";
+    public static final String EXTRA_BUNDLE = "com.android.server.nearby.fastpair.BUNDLE_EXTRA";
+    public static final String ACTION_FAST_PAIR_HALF_SHEET_CANCEL =
+            "com.android.nearby.ACTION_FAST_PAIR_HALF_SHEET_CANCEL";
+    public static final String EXTRA_HALF_SHEET_INFO =
+            "com.android.nearby.halfsheet.HALF_SHEET";
+    public static final String EXTRA_HALF_SHEET_TYPE =
+            "com.android.nearby.halfsheet.HALF_SHEET_TYPE";
+    public static final String DEVICE_PAIRING_FRAGMENT_TYPE = "DEVICE_PAIRING";
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/FastPairAdvHandler.java b/nearby/service/java/com/android/server/nearby/fastpair/FastPairAdvHandler.java
new file mode 100644
index 0000000..b175234
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/FastPairAdvHandler.java
@@ -0,0 +1,215 @@
+/*
+ * 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.fastpair;
+
+import static com.android.server.nearby.fastpair.Constant.TAG;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import android.accounts.Account;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.nearby.FastPairDevice;
+import android.nearby.NearbyDevice;
+import android.util.Log;
+
+import com.android.server.nearby.common.ble.decode.FastPairDecoder;
+import com.android.server.nearby.common.bloomfilter.BloomFilter;
+import com.android.server.nearby.common.bloomfilter.FastPairBloomFilterHasher;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.fastpair.cache.FastPairCacheManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+import com.android.server.nearby.provider.FastPairDataProvider;
+import com.android.server.nearby.util.DataUtils;
+import com.android.server.nearby.util.Hex;
+
+import java.util.List;
+
+import service.proto.Cache;
+import service.proto.Data;
+import service.proto.Rpcs;
+
+/**
+ * Handler that handle fast pair related broadcast.
+ */
+public class FastPairAdvHandler {
+    Context mContext;
+    String mBleAddress;
+    // Need to be deleted after notification manager in use.
+    private boolean mIsFirst = false;
+
+    /** The types about how the bloomfilter is processed. */
+    public enum ProcessBloomFilterType {
+        IGNORE, // The bloomfilter is not handled. e.g. distance is too far away.
+        CACHE, // The bloomfilter is recognized in the local cache.
+        FOOTPRINT, // Need to check the bloomfilter from the footprints.
+        ACCOUNT_KEY_HIT // The specified account key was hit the bloom filter.
+    }
+
+    /**
+     * Constructor function.
+     */
+    public FastPairAdvHandler(Context context) {
+        mContext = context;
+    }
+
+    /**
+     * Handles all of the scanner result. Fast Pair will handle model id broadcast bloomfilter
+     * broadcast and battery level broadcast.
+     */
+    public void handleBroadcast(NearbyDevice device) {
+        FastPairDevice fastPairDevice = (FastPairDevice) device;
+        mBleAddress = fastPairDevice.getBluetoothAddress();
+        FastPairDataProvider dataProvider = FastPairDataProvider.getInstance();
+        if (dataProvider == null) {
+            return;
+        }
+        List<Account> accountList = dataProvider.loadFastPairEligibleAccounts();
+        if (FastPairDecoder.checkModelId(fastPairDevice.getData())) {
+            byte[] model = FastPairDecoder.getModelId(fastPairDevice.getData());
+            Log.d(TAG, "On discovery model id " + Hex.bytesToStringLowercase(model));
+            // Use api to get anti spoofing key from model id.
+            try {
+                Rpcs.GetObservedDeviceResponse response =
+                        dataProvider.loadFastPairAntispoofKeyDeviceMetadata(model);
+                if (response == null) {
+                    Log.e(TAG, "server does not have model id "
+                            + Hex.bytesToStringLowercase(model));
+                    return;
+                }
+                Locator.get(mContext, FastPairHalfSheetManager.class).showHalfSheet(
+                        DataUtils.toScanFastPairStoreItem(
+                                response, mBleAddress,
+                                accountList.isEmpty() ? null : accountList.get(0).name));
+            } catch (IllegalStateException e) {
+                Log.e(TAG, "OEM does not construct fast pair data proxy correctly");
+            }
+        } else {
+            // Start to process bloom filter
+            try {
+                byte[] bloomFilterByteArray = FastPairDecoder
+                        .getBloomFilter(fastPairDevice.getData());
+                byte[] bloomFilterSalt = FastPairDecoder
+                        .getBloomFilterSalt(fastPairDevice.getData());
+                if (bloomFilterByteArray == null || bloomFilterByteArray.length == 0) {
+                    return;
+                }
+                for (Account account : accountList) {
+                    List<Data.FastPairDeviceWithAccountKey> listDevices =
+                            dataProvider.loadFastPairDeviceWithAccountKey(account);
+                    Data.FastPairDeviceWithAccountKey recognizedDevice =
+                            findRecognizedDevice(listDevices,
+                                    new BloomFilter(bloomFilterByteArray,
+                                            new FastPairBloomFilterHasher()), bloomFilterSalt);
+
+                    if (recognizedDevice != null) {
+                        Log.d(TAG, "find matched device show notification to remind"
+                                + " user to pair");
+                        // Check if the device is already paired
+                        List<Cache.StoredFastPairItem> storedFastPairItemList =
+                                Locator.get(mContext, FastPairCacheManager.class)
+                                        .getAllSavedStoredFastPairItem();
+                        Cache.StoredFastPairItem recognizedStoredFastPairItem =
+                                findRecognizedDeviceFromCachedItem(storedFastPairItemList,
+                                        new BloomFilter(bloomFilterByteArray,
+                                                new FastPairBloomFilterHasher()), bloomFilterSalt);
+                        if (recognizedStoredFastPairItem != null) {
+                            // The bloomfilter is recognized in the cache so the device is paired
+                            // before
+                            Log.d(TAG, "bloom filter is recognized in the cache");
+                            continue;
+                        } else {
+                            Log.d(TAG, "bloom filter is recognized not paired before should"
+                                    + "show subsequent pairing notification");
+                            if (mIsFirst) {
+                                mIsFirst = false;
+                                // Get full info from api the initial request will only return
+                                // part of the info due to size limit.
+                                List<Data.FastPairDeviceWithAccountKey> resList =
+                                        dataProvider.loadFastPairDeviceWithAccountKey(account,
+                                        List.of(recognizedDevice.getAccountKey().toByteArray()));
+                                if (resList != null && resList.size() > 0) {
+                                    //Saved device from footprint does not have ble address so
+                                    // fill ble address with current scan result.
+                                    Cache.StoredDiscoveryItem storedDiscoveryItem =
+                                            resList.get(0).getDiscoveryItem().toBuilder()
+                                                    .setMacAddress(
+                                                            fastPairDevice.getBluetoothAddress())
+                                                    .build();
+                                    Locator.get(mContext, FastPairController.class).pair(
+                                            new DiscoveryItem(mContext, storedDiscoveryItem),
+                                            resList.get(0).getAccountKey().toByteArray(),
+                                            /** companionApp=*/ null);
+                                }
+                            }
+                        }
+
+                        return;
+                    }
+                }
+            } catch (IllegalStateException e) {
+                Log.e(TAG, "OEM does not construct fast pair data proxy correctly");
+            }
+
+        }
+    }
+
+    /**
+     * Checks the bloom filter to see if any of the devices are recognized and should have a
+     * notification displayed for them. A device is recognized if the account key + salt combination
+     * is inside the bloom filter.
+     */
+    @Nullable
+    static Data.FastPairDeviceWithAccountKey findRecognizedDevice(
+            List<Data.FastPairDeviceWithAccountKey> devices, BloomFilter bloomFilter, byte[] salt) {
+        Log.d(TAG, "saved devices size in the account is " + devices.size());
+        for (Data.FastPairDeviceWithAccountKey device : devices) {
+            if (device.getAccountKey().toByteArray() == null || salt == null) {
+                return null;
+            }
+            byte[] rotatedKey = concat(device.getAccountKey().toByteArray(), salt);
+            StringBuilder sb = new StringBuilder();
+            for (byte b : rotatedKey) {
+                sb.append(b);
+            }
+            if (bloomFilter.possiblyContains(rotatedKey)) {
+                Log.d(TAG, "match " + sb.toString());
+                return device;
+            } else {
+                Log.d(TAG, "not match " + sb.toString());
+            }
+        }
+        return null;
+    }
+
+    @Nullable
+    static Cache.StoredFastPairItem findRecognizedDeviceFromCachedItem(
+            List<Cache.StoredFastPairItem> devices, BloomFilter bloomFilter, byte[] salt) {
+        for (Cache.StoredFastPairItem device : devices) {
+            if (device.getAccountKey().toByteArray() == null || salt == null) {
+                return null;
+            }
+            byte[] rotatedKey = concat(device.getAccountKey().toByteArray(), salt);
+            if (bloomFilter.possiblyContains(rotatedKey)) {
+                return device;
+            }
+        }
+        return null;
+    }
+
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/FastPairController.java b/nearby/service/java/com/android/server/nearby/fastpair/FastPairController.java
new file mode 100644
index 0000000..e1db7e5
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/FastPairController.java
@@ -0,0 +1,323 @@
+/*
+ * 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.fastpair;
+
+import static com.google.common.primitives.Bytes.concat;
+
+import android.accounts.Account;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.nearby.FastPairDevice;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.WorkerThread;
+
+import com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress;
+import com.android.server.nearby.common.eventloop.Annotations;
+import com.android.server.nearby.common.eventloop.EventLoop;
+import com.android.server.nearby.common.eventloop.NamedRunnable;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.fastpair.cache.FastPairCacheManager;
+import com.android.server.nearby.fastpair.footprint.FastPairUploadInfo;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+import com.android.server.nearby.fastpair.notification.FastPairNotificationManager;
+import com.android.server.nearby.fastpair.pairinghandler.PairingProgressHandlerBase;
+import com.android.server.nearby.provider.FastPairDataProvider;
+
+import com.google.common.hash.Hashing;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import service.proto.Cache;
+
+/**
+ * FastPair controller after get the info from intent handler Fast Pair controller is responsible
+ * for pairing control.
+ */
+public class FastPairController {
+    private static final String TAG = "FastPairController";
+    private final Context mContext;
+    private final EventLoop mEventLoop;
+    private final FastPairCacheManager mFastPairCacheManager;
+    private final FootprintsDeviceManager mFootprintsDeviceManager;
+    private boolean mIsFastPairing = false;
+    // boolean flag whether upload to footprint or not.
+    private boolean mShouldUpload = false;
+    @Nullable
+    private Callback mCallback;
+
+    public FastPairController(Context context) {
+        mContext = context;
+        mEventLoop = Locator.get(mContext, EventLoop.class);
+        mFastPairCacheManager = Locator.get(mContext, FastPairCacheManager.class);
+        mFootprintsDeviceManager = Locator.get(mContext, FootprintsDeviceManager.class);
+    }
+
+    /**
+     * Should be called on create lifecycle.
+     */
+    @WorkerThread
+    public void onCreate() {
+        mEventLoop.postRunnable(new NamedRunnable("FastPairController::InitializeScanner") {
+            @Override
+            public void run() {
+                // init scanner here and start scan.
+            }
+        });
+    }
+
+    /**
+     * Should be called on destroy lifecycle.
+     */
+    @WorkerThread
+    public void onDestroy() {
+        mEventLoop.postRunnable(new NamedRunnable("FastPairController::DestroyScanner") {
+            @Override
+            public void run() {
+                // Unregister scanner from here
+            }
+        });
+    }
+
+    /**
+     * Pairing function.
+     */
+    public void pair(FastPairDevice fastPairDevice) {
+        byte[] discoveryItem = fastPairDevice.getData();
+        String modelId = fastPairDevice.getModelId();
+
+        Log.v(TAG, "pair: fastPairDevice " + fastPairDevice);
+        mEventLoop.postRunnable(
+                new NamedRunnable("fastPairWith=" + modelId) {
+                    @Override
+                    public void run() {
+                        try {
+                            DiscoveryItem item = new DiscoveryItem(mContext,
+                                    Cache.StoredDiscoveryItem.parseFrom(discoveryItem));
+                            if (TextUtils.isEmpty(item.getMacAddress())) {
+                                Log.w(TAG, "There is no mac address in the DiscoveryItem,"
+                                        + " ignore pairing");
+                                return;
+                            }
+                            // Check enabled state to prevent multiple pair attempts if we get the
+                            // intent more than once (this can happen due to an Android platform
+                            // bug - b/31459521).
+                            if (item.getState()
+                                    != Cache.StoredDiscoveryItem.State.STATE_ENABLED) {
+                                Log.d(TAG, "Incorrect state, ignore pairing");
+                                return;
+                            }
+                            boolean useLargeNotifications =
+                                    item.getAuthenticationPublicKeySecp256R1() != null;
+                            FastPairNotificationManager fastPairNotificationManager =
+                                    new FastPairNotificationManager(mContext, item,
+                                            useLargeNotifications);
+                            FastPairHalfSheetManager fastPairHalfSheetManager =
+                                    Locator.get(mContext, FastPairHalfSheetManager.class);
+                            mFastPairCacheManager.saveDiscoveryItem(item);
+
+                            PairingProgressHandlerBase pairingProgressHandlerBase =
+                                    PairingProgressHandlerBase.create(
+                                            mContext,
+                                            item,
+                                            /* companionApp= */ null,
+                                            /* accountKey= */ null,
+                                            mFootprintsDeviceManager,
+                                            fastPairNotificationManager,
+                                            fastPairHalfSheetManager,
+                                            /* isRetroactivePair= */ false);
+
+                            pair(item,
+                                    /* accountKey= */ null,
+                                    /* companionApp= */ null,
+                                    pairingProgressHandlerBase);
+                        } catch (InvalidProtocolBufferException e) {
+                            Log.w(TAG,
+                                    "Error parsing serialized discovery item with size "
+                                            + discoveryItem.length);
+                        }
+                    }
+                });
+    }
+
+    /**
+     * Subsequent pairing entry.
+     */
+    public void pair(DiscoveryItem item,
+            @Nullable byte[] accountKey,
+            @Nullable String companionApp) {
+        FastPairNotificationManager fastPairNotificationManager =
+                new FastPairNotificationManager(mContext, item, false);
+        FastPairHalfSheetManager fastPairHalfSheetManager =
+                Locator.get(mContext, FastPairHalfSheetManager.class);
+        PairingProgressHandlerBase pairingProgressHandlerBase =
+                PairingProgressHandlerBase.create(
+                        mContext,
+                        item,
+                        /* companionApp= */ null,
+                        /* accountKey= */ accountKey,
+                        mFootprintsDeviceManager,
+                        fastPairNotificationManager,
+                        fastPairHalfSheetManager,
+                        /* isRetroactivePair= */ false);
+        pair(item, accountKey, companionApp, pairingProgressHandlerBase);
+    }
+    /**
+     * Pairing function
+     */
+    @Annotations.EventThread
+    public void pair(
+            DiscoveryItem item,
+            @Nullable byte[] accountKey,
+            @Nullable String companionApp,
+            PairingProgressHandlerBase pairingProgressHandlerBase) {
+        if (mIsFastPairing) {
+            Log.d(TAG, "FastPair: fastpairing, skip pair request");
+            return;
+        }
+        mIsFastPairing = true;
+        Log.d(TAG, "FastPair: start pair");
+
+        // Hide all "tap to pair" notifications until after the flow completes.
+        mEventLoop.removeRunnable(mReEnableAllDeviceItemsRunnable);
+        if (mCallback != null) {
+            mCallback.fastPairUpdateDeviceItemsEnabled(false);
+        }
+
+        Future<Void> task =
+                FastPairManager.pair(
+                        Executors.newSingleThreadExecutor(),
+                        mContext,
+                        item,
+                        accountKey,
+                        companionApp,
+                        mFootprintsDeviceManager,
+                        pairingProgressHandlerBase);
+        mIsFastPairing = false;
+    }
+
+    /** Fixes a companion app package name with extra spaces. */
+    private static String trimCompanionApp(String companionApp) {
+        return companionApp == null ? null : companionApp.trim();
+    }
+
+    /**
+     * Function to handle when scanner find bloomfilter.
+     */
+    @Annotations.EventThread
+    public FastPairAdvHandler.ProcessBloomFilterType onBloomFilterDetect(FastPairAdvHandler handler,
+            boolean advertiseInRange) {
+        if (mIsFastPairing) {
+            return FastPairAdvHandler.ProcessBloomFilterType.IGNORE;
+        }
+        // Check if the device is in the cache or footprint.
+        return FastPairAdvHandler.ProcessBloomFilterType.CACHE;
+    }
+
+    /**
+     * Add newly paired device info to footprint
+     */
+    @WorkerThread
+    public void addDeviceToFootprint(String publicAddress, byte[] accountKey,
+            DiscoveryItem discoveryItem) {
+        if (!mShouldUpload) {
+            return;
+        }
+        Log.d(TAG, "upload device to footprint");
+        FastPairManager.processBackgroundTask(() -> {
+            Cache.StoredDiscoveryItem storedDiscoveryItem =
+                    prepareStoredDiscoveryItemForFootprints(discoveryItem);
+            byte[] hashValue =
+                    Hashing.sha256()
+                            .hashBytes(
+                                    concat(accountKey, BluetoothAddress.decode(publicAddress)))
+                            .asBytes();
+            FastPairUploadInfo uploadInfo =
+                    new FastPairUploadInfo(storedDiscoveryItem, ByteString.copyFrom(accountKey),
+                            ByteString.copyFrom(hashValue));
+            // account data place holder here
+            try {
+                FastPairDataProvider fastPairDataProvider = FastPairDataProvider.getInstance();
+                if (fastPairDataProvider == null) {
+                    return;
+                }
+                List<Account> accountList = fastPairDataProvider.loadFastPairEligibleAccounts();
+                if (accountList.size() > 0) {
+                    fastPairDataProvider.optIn(accountList.get(0));
+                    fastPairDataProvider.upload(accountList.get(0), uploadInfo);
+                }
+            } catch (IllegalStateException e) {
+                Log.e(TAG, "OEM does not construct fast pair data proxy correctly");
+            }
+        });
+    }
+
+    @Nullable
+    private Cache.StoredDiscoveryItem getStoredDiscoveryItemFromAddressForFootprints(
+            String bleAddress) {
+
+        List<DiscoveryItem> discoveryItems = new ArrayList<>();
+        //cacheManager.getAllDiscoveryItems();
+        for (DiscoveryItem discoveryItem : discoveryItems) {
+            if (bleAddress.equals(discoveryItem.getMacAddress())) {
+                return prepareStoredDiscoveryItemForFootprints(discoveryItem);
+            }
+        }
+        return null;
+    }
+
+    static Cache.StoredDiscoveryItem prepareStoredDiscoveryItemForFootprints(
+            DiscoveryItem discoveryItem) {
+        Cache.StoredDiscoveryItem.Builder storedDiscoveryItem =
+                discoveryItem.getCopyOfStoredItem().toBuilder();
+        // Strip the mac address so we aren't storing it in the cloud and ensure the item always
+        // starts as enabled and in a good state.
+        storedDiscoveryItem.clearMacAddress();
+
+        return storedDiscoveryItem.build();
+    }
+
+    /**
+     * FastPairConnection will check whether write account key result if the account key is
+     * generated change the parameter.
+     */
+    public void setShouldUpload(boolean shouldUpload) {
+        mShouldUpload = shouldUpload;
+    }
+
+    private final NamedRunnable mReEnableAllDeviceItemsRunnable =
+            new NamedRunnable("reEnableAllDeviceItems") {
+                @Override
+                public void run() {
+                    if (mCallback != null) {
+                        mCallback.fastPairUpdateDeviceItemsEnabled(true);
+                    }
+                }
+            };
+
+    interface Callback {
+        void fastPairUpdateDeviceItemsEnabled(boolean enabled);
+    }
+}
\ No newline at end of file
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/FastPairManager.java b/nearby/service/java/com/android/server/nearby/fastpair/FastPairManager.java
new file mode 100644
index 0000000..16c04de
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/FastPairManager.java
@@ -0,0 +1,460 @@
+/*
+ * 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.fastpair;
+
+import static com.android.server.nearby.fastpair.Constant.TAG;
+
+import android.annotation.Nullable;
+import android.annotation.WorkerThread;
+import android.app.KeyguardManager;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.ContentObserver;
+import android.nearby.FastPairDevice;
+import android.nearby.NearbyDevice;
+import android.nearby.NearbyManager;
+import android.nearby.ScanCallback;
+import android.nearby.ScanRequest;
+import android.net.Uri;
+import android.provider.Settings;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.nearby.common.ble.decode.FastPairDecoder;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.fastpair.FastPairConnection;
+import com.android.server.nearby.common.bluetooth.fastpair.FastPairDualConnection;
+import com.android.server.nearby.common.bluetooth.fastpair.PairingException;
+import com.android.server.nearby.common.bluetooth.fastpair.Preferences;
+import com.android.server.nearby.common.bluetooth.fastpair.ReflectionException;
+import com.android.server.nearby.common.bluetooth.fastpair.SimpleBroadcastReceiver;
+import com.android.server.nearby.common.eventloop.Annotations;
+import com.android.server.nearby.common.eventloop.EventLoop;
+import com.android.server.nearby.common.eventloop.NamedRunnable;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.fastpair.cache.FastPairCacheManager;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+import com.android.server.nearby.fastpair.pairinghandler.PairingProgressHandlerBase;
+import com.android.server.nearby.util.ForegroundThread;
+import com.android.server.nearby.util.Hex;
+
+import com.google.common.collect.ImmutableList;
+import com.google.protobuf.ByteString;
+
+import java.security.GeneralSecurityException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import service.proto.Cache;
+import service.proto.Rpcs;
+
+/**
+ * FastPairManager is the class initiated in nearby service to handle Fast Pair related
+ * work.
+ */
+
+public class FastPairManager {
+
+    private static final String ACTION_PREFIX = UserActionHandler.PREFIX;
+    private static final int WAIT_FOR_UNLOCK_MILLIS = 5000;
+
+    /** A notification ID which should be dismissed */
+    public static final String EXTRA_NOTIFICATION_ID = ACTION_PREFIX + "EXTRA_NOTIFICATION_ID";
+    public static final String ACTION_RESOURCES_APK = "android.nearby.SHOW_HALFSHEET";
+
+    private static Executor sFastPairExecutor;
+
+    private ContentObserver mFastPairScanChangeContentObserver = null;
+
+    final LocatorContextWrapper mLocatorContextWrapper;
+    final IntentFilter mIntentFilter;
+    final Locator mLocator;
+    private boolean mScanEnabled;
+
+    private final BroadcastReceiver mScreenBroadcastReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (intent.getAction().equals(Intent.ACTION_SCREEN_ON)) {
+                Log.d(TAG, "onReceive: ACTION_SCREEN_ON.");
+                invalidateScan();
+            } else if (intent.getAction().equals(BluetoothDevice.ACTION_BOND_STATE_CHANGED)) {
+                processBluetoothConnectionEvent(intent);
+            }
+        }
+    };
+
+    public FastPairManager(LocatorContextWrapper contextWrapper) {
+        mLocatorContextWrapper = contextWrapper;
+        mIntentFilter = new IntentFilter();
+        mLocator = mLocatorContextWrapper.getLocator();
+        mLocator.bind(new FastPairModule());
+        Rpcs.GetObservedDeviceResponse getObservedDeviceResponse =
+                Rpcs.GetObservedDeviceResponse.newBuilder().build();
+    }
+
+    final ScanCallback mScanCallback = new ScanCallback() {
+        @Override
+        public void onDiscovered(@NonNull NearbyDevice device) {
+            Locator.get(mLocatorContextWrapper, FastPairAdvHandler.class).handleBroadcast(device);
+        }
+
+        @Override
+        public void onUpdated(@NonNull NearbyDevice device) {
+            FastPairDevice fastPairDevice = (FastPairDevice) device;
+            byte[] modelArray = FastPairDecoder.getModelId(fastPairDevice.getData());
+            Log.d(TAG, "update model id" + Hex.bytesToStringLowercase(modelArray));
+        }
+
+        @Override
+        public void onLost(@NonNull NearbyDevice device) {
+            FastPairDevice fastPairDevice = (FastPairDevice) device;
+            byte[] modelArray = FastPairDecoder.getModelId(fastPairDevice.getData());
+            Log.d(TAG, "lost model id" + Hex.bytesToStringLowercase(modelArray));
+        }
+    };
+
+    /**
+     * Function called when nearby service start.
+     */
+    public void initiate() {
+        mIntentFilter.addAction(Intent.ACTION_SCREEN_ON);
+        mIntentFilter.addAction(Intent.ACTION_SCREEN_OFF);
+        mIntentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+
+        mLocatorContextWrapper.getContext()
+                .registerReceiver(mScreenBroadcastReceiver, mIntentFilter);
+
+        Locator.getFromContextWrapper(mLocatorContextWrapper, FastPairCacheManager.class);
+        // Default false for now.
+        mScanEnabled = NearbyManager.getFastPairScanEnabled(mLocatorContextWrapper.getContext());
+        registerFastPairScanChangeContentObserver(mLocatorContextWrapper.getContentResolver());
+    }
+
+    /**
+     * Function to free up fast pair resource.
+     */
+    public void cleanUp() {
+        mLocatorContextWrapper.getContext().unregisterReceiver(mScreenBroadcastReceiver);
+        if (mFastPairScanChangeContentObserver != null) {
+            mLocatorContextWrapper.getContentResolver().unregisterContentObserver(
+                    mFastPairScanChangeContentObserver);
+        }
+    }
+
+    /**
+     * Starts fast pair process.
+     */
+    @Annotations.EventThread
+    public static Future<Void> pair(
+            ExecutorService executor,
+            Context context,
+            DiscoveryItem item,
+            @Nullable byte[] accountKey,
+            @Nullable String companionApp,
+            FootprintsDeviceManager footprints,
+            PairingProgressHandlerBase pairingProgressHandlerBase) {
+        return executor.submit(
+                () -> pairInternal(context, item, companionApp, accountKey, footprints,
+                        pairingProgressHandlerBase), /* result= */ null);
+    }
+
+    /**
+     * Starts fast pair
+     */
+    @WorkerThread
+    public static void pairInternal(
+            Context context,
+            DiscoveryItem item,
+            @Nullable String companionApp,
+            @Nullable byte[] accountKey,
+            FootprintsDeviceManager footprints,
+            PairingProgressHandlerBase pairingProgressHandlerBase) {
+        FastPairHalfSheetManager fastPairHalfSheetManager =
+                Locator.get(context, FastPairHalfSheetManager.class);
+        try {
+            pairingProgressHandlerBase.onPairingStarted();
+            if (pairingProgressHandlerBase.skipWaitingScreenUnlock()) {
+                // Do nothing due to we are not showing the status notification in some pairing
+                // types, e.g. the retroactive pairing.
+            } else {
+                // If the screen is locked when the user taps to pair, the screen will unlock. We
+                // must wait for the unlock to complete before showing the status notification, or
+                // it won't be heads-up.
+                pairingProgressHandlerBase.onWaitForScreenUnlock();
+                waitUntilScreenIsUnlocked(context);
+                pairingProgressHandlerBase.onScreenUnlocked();
+            }
+            BluetoothAdapter bluetoothAdapter = getBluetoothAdapter(context);
+
+            boolean isBluetoothEnabled = bluetoothAdapter != null && bluetoothAdapter.isEnabled();
+            if (!isBluetoothEnabled) {
+                if (bluetoothAdapter == null || !bluetoothAdapter.enable()) {
+                    Log.d(TAG, "FastPair: Failed to enable bluetooth");
+                    return;
+                }
+                Log.v(TAG, "FastPair: Enabling bluetooth for fast pair");
+
+                Locator.get(context, EventLoop.class)
+                        .postRunnable(
+                                new NamedRunnable("enableBluetoothToast") {
+                                    @Override
+                                    public void run() {
+                                        Log.d(TAG, "Enable bluetooth toast test");
+                                    }
+                                });
+                // Set up call back to call this function again once bluetooth has been
+                // enabled; this does not seem to be a problem as the device connects without a
+                // problem, but in theory the timeout also includes turning on bluetooth now.
+            }
+
+            pairingProgressHandlerBase.onReadyToPair();
+
+            String modelId = item.getTriggerId();
+            Preferences.Builder prefsBuilder =
+                    Preferences.builderFromGmsLog()
+                            .setEnableBrEdrHandover(false)
+                            .setIgnoreDiscoveryError(true);
+            pairingProgressHandlerBase.onSetupPreferencesBuilder(prefsBuilder);
+            if (item.getFastPairInformation() != null) {
+                prefsBuilder.setSkipConnectingProfiles(
+                        item.getFastPairInformation().getDataOnlyConnection());
+            }
+            // When add watch and auto device needs to change the config
+            prefsBuilder.setRejectMessageAccess(true);
+            prefsBuilder.setRejectPhonebookAccess(true);
+            prefsBuilder.setHandlePasskeyConfirmationByUi(false);
+
+            FastPairConnection connection = new FastPairDualConnection(
+                    context, item.getMacAddress(),
+                    prefsBuilder.build(),
+                    null);
+            pairingProgressHandlerBase.onPairingSetupCompleted();
+
+            FastPairConnection.SharedSecret sharedSecret;
+            if ((accountKey != null || item.getAuthenticationPublicKeySecp256R1() != null)) {
+                sharedSecret =
+                        connection.pair(
+                                accountKey != null ? accountKey
+                                        : item.getAuthenticationPublicKeySecp256R1());
+                if (accountKey == null) {
+                    // Account key is null so it is initial pairing
+                    if (sharedSecret != null) {
+                        Locator.get(context, FastPairController.class).addDeviceToFootprint(
+                                connection.getPublicAddress(), sharedSecret.getKey(), item);
+                        cacheFastPairDevice(context, connection.getPublicAddress(),
+                                sharedSecret.getKey(), item);
+                    }
+                }
+            } else {
+                // Fast Pair one
+                connection.pair();
+            }
+            // TODO(b/213373051): Merge logic with pairingProgressHandlerBase or delete the
+            // pairingProgressHandlerBase class.
+            fastPairHalfSheetManager.showPairingSuccessHalfSheet(connection.getPublicAddress());
+            pairingProgressHandlerBase.onPairingSuccess(connection.getPublicAddress());
+        } catch (BluetoothException
+                | InterruptedException
+                | ReflectionException
+                | TimeoutException
+                | ExecutionException
+                | PairingException
+                | GeneralSecurityException e) {
+            Log.e(TAG, "Failed to pair.", e);
+
+            // TODO(b/213373051): Merge logic with pairingProgressHandlerBase or delete the
+            // pairingProgressHandlerBase class.
+            fastPairHalfSheetManager.showPairingFailed();
+            pairingProgressHandlerBase.onPairingFailed(e);
+        }
+    }
+
+    private static void cacheFastPairDevice(Context context, String publicAddress, byte[] key,
+            DiscoveryItem item) {
+        try {
+            Locator.get(context, EventLoop.class).postAndWait(
+                    new NamedRunnable("FastPairCacheDevice") {
+                        @Override
+                        public void run() {
+                            Cache.StoredFastPairItem storedFastPairItem =
+                                    Cache.StoredFastPairItem.newBuilder()
+                                            .setMacAddress(publicAddress)
+                                            .setAccountKey(ByteString.copyFrom(key))
+                                            .setModelId(item.getTriggerId())
+                                            .addAllFeatures(item.getFastPairInformation() == null
+                                                    ? ImmutableList.of() :
+                                                    item.getFastPairInformation().getFeaturesList())
+                                            .setDiscoveryItem(item.getCopyOfStoredItem())
+                                            .build();
+                            Locator.get(context, FastPairCacheManager.class)
+                                    .putStoredFastPairItem(storedFastPairItem);
+                        }
+                    }
+            );
+        } catch (InterruptedException e) {
+            Log.e(TAG, "Fail to insert paired device into cache");
+        }
+    }
+
+    /** Checks if the pairing is initial pairing with fast pair 2.0 design. */
+    public static boolean isThroughFastPair2InitialPairing(
+            DiscoveryItem item, @Nullable byte[] accountKey) {
+        return accountKey == null && item.getAuthenticationPublicKeySecp256R1() != null;
+    }
+
+    private static void waitUntilScreenIsUnlocked(Context context)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        KeyguardManager keyguardManager = context.getSystemService(KeyguardManager.class);
+
+        // KeyguardManager's isScreenLocked() counterintuitively returns false when the lock screen
+        // is showing if the user has set "swipe to unlock" (i.e. no required password, PIN, or
+        // pattern) So we use this method instead, which returns true when on the lock screen
+        // regardless.
+        if (keyguardManager.isKeyguardLocked()) {
+            Log.v(TAG, "FastPair: Screen is locked, waiting until unlocked "
+                    + "to show status notifications.");
+            try (SimpleBroadcastReceiver isUnlockedReceiver =
+                         SimpleBroadcastReceiver.oneShotReceiver(
+                                 context, FlagUtils.getPreferencesBuilder().build(),
+                                 Intent.ACTION_USER_PRESENT)) {
+                isUnlockedReceiver.await(WAIT_FOR_UNLOCK_MILLIS, TimeUnit.MILLISECONDS);
+            }
+        }
+    }
+
+    private void registerFastPairScanChangeContentObserver(ContentResolver resolver) {
+        mFastPairScanChangeContentObserver = new ContentObserver(ForegroundThread.getHandler()) {
+            @Override
+            public void onChange(boolean selfChange, Uri uri) {
+                super.onChange(selfChange, uri);
+                setScanEnabled(
+                        NearbyManager.getFastPairScanEnabled(mLocatorContextWrapper.getContext()));
+            }
+        };
+        try {
+            resolver.registerContentObserver(
+                    Settings.Secure.getUriFor(NearbyManager.FAST_PAIR_SCAN_ENABLED),
+                    /* notifyForDescendants= */ false,
+                    mFastPairScanChangeContentObserver);
+        } catch (SecurityException e) {
+            Log.e(TAG, "Failed to register content observer for fast pair scan.", e);
+        }
+    }
+
+    /**
+     * Processed task in a background thread
+     */
+    @Annotations.EventThread
+    public static void processBackgroundTask(Runnable runnable) {
+        getExecutor().execute(runnable);
+    }
+
+    /**
+     * This function should only be called on main thread since there is no lock
+     */
+    private static Executor getExecutor() {
+        if (sFastPairExecutor != null) {
+            return sFastPairExecutor;
+        }
+        sFastPairExecutor = Executors.newSingleThreadExecutor();
+        return sFastPairExecutor;
+    }
+
+    /**
+     * Null when the Nearby Service is not available.
+     */
+    @Nullable
+    private NearbyManager getNearbyManager() {
+        return (NearbyManager) mLocatorContextWrapper
+                .getApplicationContext().getSystemService(Context.NEARBY_SERVICE);
+    }
+    private void setScanEnabled(boolean scanEnabled) {
+        if (mScanEnabled == scanEnabled) {
+            return;
+        }
+        mScanEnabled = scanEnabled;
+        invalidateScan();
+    }
+
+    /**
+     * Starts or stops scanning according to mAllowScan value.
+     */
+    private void invalidateScan() {
+        NearbyManager nearbyManager = getNearbyManager();
+        if (nearbyManager == null) {
+            Log.w(TAG, "invalidateScan: "
+                    + "failed to start or stop scannning because NearbyManager is null.");
+            return;
+        }
+        if (mScanEnabled) {
+            nearbyManager.startScan(new ScanRequest.Builder()
+                            .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR).build(),
+                    ForegroundThread.getExecutor(),
+                    mScanCallback);
+        } else {
+            nearbyManager.stopScan(mScanCallback);
+        }
+    }
+
+    /**
+     * When certain device is forgotten we need to remove the info from database because the info
+     * is no longer useful.
+     */
+    private void processBluetoothConnectionEvent(Intent intent) {
+        int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE,
+                BluetoothDevice.ERROR);
+        if (bondState == BluetoothDevice.BOND_NONE) {
+            BluetoothDevice device =
+                    intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+            if (device != null) {
+                Log.d("FastPairService", "Forget device detect");
+                processBackgroundTask(new Runnable() {
+                    @Override
+                    public void run() {
+                        mLocatorContextWrapper.getLocator().get(FastPairCacheManager.class)
+                                .removeStoredFastPairItem(device.getAddress());
+                    }
+                });
+            }
+
+        }
+    }
+
+    /**
+     * Helper function to get bluetooth adapter.
+     */
+    @Nullable
+    public static BluetoothAdapter getBluetoothAdapter(Context context) {
+        BluetoothManager manager = context.getSystemService(BluetoothManager.class);
+        return manager == null ? null : manager.getAdapter();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/FastPairModule.java b/nearby/service/java/com/android/server/nearby/fastpair/FastPairModule.java
new file mode 100644
index 0000000..d7946d1
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/FastPairModule.java
@@ -0,0 +1,83 @@
+/*
+ * 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.fastpair;
+
+import android.content.Context;
+
+import com.android.server.nearby.common.eventloop.EventLoop;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.Module;
+import com.android.server.nearby.fastpair.cache.FastPairCacheManager;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+
+/**
+ * Module that associates all of the fast pair related singleton class
+ */
+public class FastPairModule extends Module {
+    /**
+     * Initiate the class that needs to be singleton.
+     */
+    @Override
+    public void configure(Context context, Class<?> type, Locator locator) {
+        if (type.equals(FastPairCacheManager.class)) {
+            locator.bind(FastPairCacheManager.class, new FastPairCacheManager(context));
+        } else if (type.equals(FootprintsDeviceManager.class)) {
+            locator.bind(FootprintsDeviceManager.class, new FootprintsDeviceManager());
+        } else if (type.equals(EventLoop.class)) {
+            locator.bind(EventLoop.class, EventLoop.newInstance("NearbyFastPair"));
+        } else if (type.equals(FastPairController.class)) {
+            locator.bind(FastPairController.class, new FastPairController(context));
+        } else if (type.equals(FastPairCacheManager.class)) {
+            locator.bind(FastPairCacheManager.class, new FastPairCacheManager(context));
+        } else if (type.equals(FastPairHalfSheetManager.class)) {
+            locator.bind(FastPairHalfSheetManager.class, new FastPairHalfSheetManager(context));
+        } else if (type.equals(FastPairAdvHandler.class)) {
+            locator.bind(FastPairAdvHandler.class, new FastPairAdvHandler(context));
+        } else if (type.equals(Clock.class)) {
+            locator.bind(Clock.class, new Clock() {
+                @Override
+                public ZoneId getZone() {
+                    return null;
+                }
+
+                @Override
+                public Clock withZone(ZoneId zone) {
+                    return null;
+                }
+
+                @Override
+                public Instant instant() {
+                    return null;
+                }
+            });
+        }
+
+    }
+
+    /**
+     * Clean up the singleton classes.
+     */
+    @Override
+    public void destroy(Context context, Class<?> type, Object instance) {
+        super.destroy(context, type, instance);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/FlagUtils.java b/nearby/service/java/com/android/server/nearby/fastpair/FlagUtils.java
new file mode 100644
index 0000000..883a1f8
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/FlagUtils.java
@@ -0,0 +1,202 @@
+/*
+ * 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.fastpair;
+
+import android.text.TextUtils;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Preferences;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * This is fast pair connection preference
+ */
+public class FlagUtils {
+    private static final int GATT_OPERATION_TIME_OUT_SECOND = 10;
+    private static final int GATT_CONNECTION_TIME_OUT_SECOND = 15;
+    private static final int BLUETOOTH_TOGGLE_TIME_OUT_SECOND = 10;
+    private static final int BLUETOOTH_TOGGLE_SLEEP_TIME_OUT_SECOND = 2;
+    private static final int CLASSIC_DISCOVERY_TIME_OUT_SECOND = 13;
+    private static final int NUM_DISCOVER_ATTEMPTS = 3;
+    private static final int DISCOVERY_RETRY_SLEEP_SECONDS = 1;
+    private static final int SDP_TIME_OUT_SECONDS = 10;
+    private static final int NUM_SDP_ATTEMPTS = 0;
+    private static final int NUM_CREATED_BOND_ATTEMPTS = 3;
+    private static final int NUM_CONNECT_ATTEMPT = 2;
+    private static final int NUM_WRITE_ACCOUNT_KEY_ATTEMPT = 3;
+    private static final boolean TOGGLE_BLUETOOTH_ON_FAILURE = false;
+    private static final boolean BLUETOOTH_STATE_POOLING = true;
+    private static final int BLUETOOTH_STATE_POOLING_MILLIS = 1000;
+    private static final int NUM_ATTEMPTS = 2;
+    private static final short BREDR_HANDOVER_DATA_CHARACTERISTIC_ID = 11265; // 0x2c01
+    private static final short BLUETOOTH_SIG_DATA_CHARACTERISTIC_ID = 11266; // 0x2c02
+    private static final short TRANSPORT_BLOCK_DATA_CHARACTERISTIC_ID = 11267; // 0x2c03
+    private static final boolean WAIT_FOR_UUID_AFTER_BONDING = true;
+    private static final boolean RECEIVE_UUID_AND_BONDED_EVENT_BEFORE_CLOSE = true;
+    private static final int REMOVE_BOND_TIME_OUT_SECONDS = 5;
+    private static final int REMOVE_BOND_SLEEP_MILLIS = 1000;
+    private static final int CREATE_BOND_TIME_OUT_SECONDS = 15;
+    private static final int HIDE_CREATED_BOND_TIME_OUT_SECONDS = 40;
+    private static final int PROXY_TIME_OUT_SECONDS = 2;
+    private static final boolean REJECT_ACCESS = false;
+    private static final boolean ACCEPT_PASSKEY = true;
+    private static final int WRITE_ACCOUNT_KEY_SLEEP_MILLIS = 2000;
+    private static final boolean PROVIDER_INITIATE_BONDING = false;
+    private static final boolean SPECIFY_CREATE_BOND_TRANSPORT_TYPE = false;
+    private static final int CREATE_BOND_TRANSPORT_TYPE = 0;
+    private static final boolean KEEP_SAME_ACCOUNT_KEY_WRITE = true;
+    private static final boolean ENABLE_NAMING_CHARACTERISTIC = true;
+    private static final boolean CHECK_FIRMWARE_VERSION = true;
+    private static final int SDP_ATTEMPTS_AFTER_BONDED = 1;
+    private static final boolean SUPPORT_HID = false;
+    private static final boolean ENABLE_PAIRING_WHILE_DIRECTLY_CONNECTING = true;
+    private static final boolean ACCEPT_CONSENT_FOR_FP_ONE = true;
+    private static final int GATT_CONNECT_RETRY_TIMEOUT_MILLIS = 18000;
+    private static final boolean ENABLE_128BIT_CUSTOM_GATT_CHARACTERISTIC = true;
+    private static final boolean ENABLE_SEND_EXCEPTION_STEP_TO_VALIDATOR = true;
+    private static final boolean ENABLE_ADDITIONAL_DATA_TYPE_WHEN_ACTION_OVER_BLE = true;
+    private static final boolean CHECK_BOND_STATE_WHEN_SKIP_CONNECTING_PROFILE = true;
+    private static final boolean MORE_LOG_FOR_QUALITY = true;
+    private static final boolean RETRY_GATT_CONNECTION_AND_SECRET_HANDSHAKE = true;
+    private static final int GATT_CONNECT_SHORT_TIMEOUT_MS = 7000;
+    private static final int GATT_CONNECTION_LONG_TIME_OUT_MS = 15000;
+    private static final int GATT_CONNECT_SHORT_TIMEOUT_RETRY_MAX_SPENT_TIME_MS = 1000;
+    private static final int ADDRESS_ROTATE_RETRY_MAX_SPENT_TIME_MS = 15000;
+    private static final int PAIRING_RETRY_DELAY_MS = 100;
+    private static final int HANDSHAKE_SHORT_TIMEOUT_MS = 3000;
+    private static final int HANDSHAKE_LONG_TIMEOUT_MS = 1000;
+    private static final int SECRET_HANDSHAKE_SHORT_TIMEOUT_RETRY_MAX_SPENT_TIME_MS = 5000;
+    private static final int SECRET_HANDSHAKE_LONG_TIMEOUT_RETRY_MAX_SPENT_TIME_MS = 7000;
+    private static final int SECRET_HANDSHAKE_RETRY_ATTEMPTS = 3;
+    private static final int SECRET_HANDSHAKE_RETRY_GATT_CONNECTION_MAX_SPENT_TIME_MS = 15000;
+    private static final int SIGNAL_LOST_RETRY_MAX_SPENT_TIME_MS = 15000;
+    private static final boolean RETRY_SECRET_HANDSHAKE_TIMEOUT = false;
+    private static final boolean LOG_USER_MANUAL_RETRY = true;
+    private static final boolean ENABLE_PAIR_FLOW_SHOW_UI_WITHOUT_PROFILE_CONNECTION = false;
+    private static final boolean LOG_USER_MANUAL_CITY = true;
+    private static final boolean LOG_PAIR_WITH_CACHED_MODEL_ID = true;
+    private static final boolean DIRECT_CONNECT_PROFILE_IF_MODEL_ID_IN_CACHE = false;
+
+    public static Preferences.Builder getPreferencesBuilder() {
+        return Preferences.builder()
+                .setGattOperationTimeoutSeconds(GATT_OPERATION_TIME_OUT_SECOND)
+                .setGattConnectionTimeoutSeconds(GATT_CONNECTION_TIME_OUT_SECOND)
+                .setBluetoothToggleTimeoutSeconds(BLUETOOTH_TOGGLE_TIME_OUT_SECOND)
+                .setBluetoothToggleSleepSeconds(BLUETOOTH_TOGGLE_SLEEP_TIME_OUT_SECOND)
+                .setClassicDiscoveryTimeoutSeconds(CLASSIC_DISCOVERY_TIME_OUT_SECOND)
+                .setNumDiscoverAttempts(NUM_DISCOVER_ATTEMPTS)
+                .setDiscoveryRetrySleepSeconds(DISCOVERY_RETRY_SLEEP_SECONDS)
+                .setSdpTimeoutSeconds(SDP_TIME_OUT_SECONDS)
+                .setNumSdpAttempts(NUM_SDP_ATTEMPTS)
+                .setNumCreateBondAttempts(NUM_CREATED_BOND_ATTEMPTS)
+                .setNumConnectAttempts(NUM_CONNECT_ATTEMPT)
+                .setNumWriteAccountKeyAttempts(NUM_WRITE_ACCOUNT_KEY_ATTEMPT)
+                .setToggleBluetoothOnFailure(TOGGLE_BLUETOOTH_ON_FAILURE)
+                .setBluetoothStateUsesPolling(BLUETOOTH_STATE_POOLING)
+                .setBluetoothStatePollingMillis(BLUETOOTH_STATE_POOLING_MILLIS)
+                .setNumAttempts(NUM_ATTEMPTS)
+                .setBrHandoverDataCharacteristicId(BREDR_HANDOVER_DATA_CHARACTERISTIC_ID)
+                .setBluetoothSigDataCharacteristicId(BLUETOOTH_SIG_DATA_CHARACTERISTIC_ID)
+                .setBrTransportBlockDataDescriptorId(TRANSPORT_BLOCK_DATA_CHARACTERISTIC_ID)
+                .setWaitForUuidsAfterBonding(WAIT_FOR_UUID_AFTER_BONDING)
+                .setReceiveUuidsAndBondedEventBeforeClose(
+                        RECEIVE_UUID_AND_BONDED_EVENT_BEFORE_CLOSE)
+                .setRemoveBondTimeoutSeconds(REMOVE_BOND_TIME_OUT_SECONDS)
+                .setRemoveBondSleepMillis(REMOVE_BOND_SLEEP_MILLIS)
+                .setCreateBondTimeoutSeconds(CREATE_BOND_TIME_OUT_SECONDS)
+                .setHidCreateBondTimeoutSeconds(HIDE_CREATED_BOND_TIME_OUT_SECONDS)
+                .setProxyTimeoutSeconds(PROXY_TIME_OUT_SECONDS)
+                .setRejectPhonebookAccess(REJECT_ACCESS)
+                .setRejectMessageAccess(REJECT_ACCESS)
+                .setRejectSimAccess(REJECT_ACCESS)
+                .setAcceptPasskey(ACCEPT_PASSKEY)
+                .setWriteAccountKeySleepMillis(WRITE_ACCOUNT_KEY_SLEEP_MILLIS)
+                .setProviderInitiatesBondingIfSupported(PROVIDER_INITIATE_BONDING)
+                .setAttemptDirectConnectionWhenPreviouslyBonded(true)
+                .setAutomaticallyReconnectGattWhenNeeded(true)
+                .setSkipDisconnectingGattBeforeWritingAccountKey(true)
+                .setIgnoreUuidTimeoutAfterBonded(true)
+                .setSpecifyCreateBondTransportType(SPECIFY_CREATE_BOND_TRANSPORT_TYPE)
+                .setCreateBondTransportType(CREATE_BOND_TRANSPORT_TYPE)
+                .setIncreaseIntentFilterPriority(true)
+                .setEvaluatePerformance(false)
+                .setKeepSameAccountKeyWrite(KEEP_SAME_ACCOUNT_KEY_WRITE)
+                .setEnableNamingCharacteristic(ENABLE_NAMING_CHARACTERISTIC)
+                .setEnableFirmwareVersionCharacteristic(CHECK_FIRMWARE_VERSION)
+                .setNumSdpAttemptsAfterBonded(SDP_ATTEMPTS_AFTER_BONDED)
+                .setSupportHidDevice(SUPPORT_HID)
+                .setEnablePairingWhileDirectlyConnecting(
+                        ENABLE_PAIRING_WHILE_DIRECTLY_CONNECTING)
+                .setAcceptConsentForFastPairOne(ACCEPT_CONSENT_FOR_FP_ONE)
+                .setGattConnectRetryTimeoutMillis(GATT_CONNECT_RETRY_TIMEOUT_MILLIS)
+                .setEnable128BitCustomGattCharacteristicsId(
+                        ENABLE_128BIT_CUSTOM_GATT_CHARACTERISTIC)
+                .setEnableSendExceptionStepToValidator(ENABLE_SEND_EXCEPTION_STEP_TO_VALIDATOR)
+                .setEnableAdditionalDataTypeWhenActionOverBle(
+                        ENABLE_ADDITIONAL_DATA_TYPE_WHEN_ACTION_OVER_BLE)
+                .setCheckBondStateWhenSkipConnectingProfiles(
+                        CHECK_BOND_STATE_WHEN_SKIP_CONNECTING_PROFILE)
+                .setMoreEventLogForQuality(MORE_LOG_FOR_QUALITY)
+                .setRetryGattConnectionAndSecretHandshake(
+                        RETRY_GATT_CONNECTION_AND_SECRET_HANDSHAKE)
+                .setGattConnectShortTimeoutMs(GATT_CONNECT_SHORT_TIMEOUT_MS)
+                .setGattConnectLongTimeoutMs(GATT_CONNECTION_LONG_TIME_OUT_MS)
+                .setGattConnectShortTimeoutRetryMaxSpentTimeMs(
+                        GATT_CONNECT_SHORT_TIMEOUT_RETRY_MAX_SPENT_TIME_MS)
+                .setAddressRotateRetryMaxSpentTimeMs(ADDRESS_ROTATE_RETRY_MAX_SPENT_TIME_MS)
+                .setPairingRetryDelayMs(PAIRING_RETRY_DELAY_MS)
+                .setSecretHandshakeShortTimeoutMs(HANDSHAKE_SHORT_TIMEOUT_MS)
+                .setSecretHandshakeLongTimeoutMs(HANDSHAKE_LONG_TIMEOUT_MS)
+                .setSecretHandshakeShortTimeoutRetryMaxSpentTimeMs(
+                        SECRET_HANDSHAKE_SHORT_TIMEOUT_RETRY_MAX_SPENT_TIME_MS)
+                .setSecretHandshakeLongTimeoutRetryMaxSpentTimeMs(
+                        SECRET_HANDSHAKE_LONG_TIMEOUT_RETRY_MAX_SPENT_TIME_MS)
+                .setSecretHandshakeRetryAttempts(SECRET_HANDSHAKE_RETRY_ATTEMPTS)
+                .setSecretHandshakeRetryGattConnectionMaxSpentTimeMs(
+                        SECRET_HANDSHAKE_RETRY_GATT_CONNECTION_MAX_SPENT_TIME_MS)
+                .setSignalLostRetryMaxSpentTimeMs(SIGNAL_LOST_RETRY_MAX_SPENT_TIME_MS)
+                .setGattConnectionAndSecretHandshakeNoRetryGattError(
+                        getGattConnectionAndSecretHandshakeNoRetryGattError())
+                .setRetrySecretHandshakeTimeout(RETRY_SECRET_HANDSHAKE_TIMEOUT)
+                .setLogUserManualRetry(LOG_USER_MANUAL_RETRY)
+                .setEnablePairFlowShowUiWithoutProfileConnection(
+                        ENABLE_PAIR_FLOW_SHOW_UI_WITHOUT_PROFILE_CONNECTION)
+                .setLogUserManualRetry(LOG_USER_MANUAL_CITY)
+                .setLogPairWithCachedModelId(LOG_PAIR_WITH_CACHED_MODEL_ID)
+                .setDirectConnectProfileIfModelIdInCache(
+                        DIRECT_CONNECT_PROFILE_IF_MODEL_ID_IN_CACHE);
+    }
+
+    private static ImmutableSet<Integer> getGattConnectionAndSecretHandshakeNoRetryGattError() {
+        ImmutableSet.Builder<Integer> noRetryGattErrorsBuilder = ImmutableSet.builder();
+        // When GATT connection fail we will not retry on error code 257
+        for (String errorCode :
+                Splitter.on(",").split("257,")) {
+            if (!TextUtils.isDigitsOnly(errorCode)) {
+                continue;
+            }
+
+            try {
+                noRetryGattErrorsBuilder.add(Integer.parseInt(errorCode));
+            } catch (NumberFormatException e) {
+                // Ignore
+            }
+        }
+        return noRetryGattErrorsBuilder.build();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/UserActionHandler.java b/nearby/service/java/com/android/server/nearby/fastpair/UserActionHandler.java
new file mode 100644
index 0000000..674633d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/UserActionHandler.java
@@ -0,0 +1,31 @@
+/*
+ * 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.fastpair;
+
+import com.android.server.nearby.common.fastpair.service.UserActionHandlerBase;
+
+/**
+ * User action handler class.
+ */
+public class UserActionHandler extends UserActionHandlerBase {
+
+    public static final String EXTRA_DISCOVERY_ITEM = PREFIX + "EXTRA_DISCOVERY_ITEM";
+    public static final String EXTRA_FAST_PAIR_SECRET = PREFIX + "EXTRA_FAST_PAIR_SECRET";
+    public static final String ACTION_FAST_PAIR = ACTION_PREFIX + "ACTION_FAST_PAIR";
+    public static final String EXTRA_PRIVATE_BLE_ADDRESS =
+            ACTION_PREFIX + "EXTRA_PRIVATE_BLE_ADDRESS";
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/cache/DiscoveryItem.java b/nearby/service/java/com/android/server/nearby/fastpair/cache/DiscoveryItem.java
new file mode 100644
index 0000000..b8a9796
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/cache/DiscoveryItem.java
@@ -0,0 +1,608 @@
+/*
+ * 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.fastpair.cache;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+import static com.android.server.nearby.fastpair.UserActionHandler.EXTRA_FAST_PAIR_SECRET;
+
+import android.annotation.IntDef;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.server.nearby.common.ble.util.RangingUtils;
+import com.android.server.nearby.common.fastpair.IconUtils;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.URISyntaxException;
+import java.time.Clock;
+import java.util.Objects;
+
+import service.proto.Cache;
+
+/**
+ * Wrapper class around StoredDiscoveryItem. A centralized place for methods related to
+ * updating/parsing StoredDiscoveryItem.
+ */
+public class DiscoveryItem implements Comparable<DiscoveryItem> {
+
+    private static final String  ACTION_FAST_PAIR =
+            "com.android.server.nearby:ACTION_FAST_PAIR";
+    private static final int BEACON_STALENESS_MILLIS = 120000;
+    private static final int ITEM_EXPIRATION_MILLIS = 20000;
+    private static final int APP_INSTALL_EXPIRATION_MILLIS = 600000;
+    private static final int ITEM_DELETABLE_MILLIS = 15000;
+
+    private final FastPairCacheManager mFastPairCacheManager;
+    private final Clock mClock;
+
+    private Cache.StoredDiscoveryItem mStoredDiscoveryItem;
+
+    /** IntDef for StoredDiscoveryItem.State */
+    @IntDef({
+            Cache.StoredDiscoveryItem.State.STATE_ENABLED_VALUE,
+            Cache.StoredDiscoveryItem.State.STATE_MUTED_VALUE,
+            Cache.StoredDiscoveryItem.State.STATE_DISABLED_BY_SYSTEM_VALUE
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface ItemState {}
+
+    public DiscoveryItem(LocatorContextWrapper locatorContextWrapper,
+            Cache.StoredDiscoveryItem mStoredDiscoveryItem) {
+        this.mFastPairCacheManager =
+                locatorContextWrapper.getLocator().get(FastPairCacheManager.class);
+        this.mClock =
+            locatorContextWrapper.getLocator().get(Clock.class);
+        this.mStoredDiscoveryItem = mStoredDiscoveryItem;
+    }
+
+    public DiscoveryItem(Context context, Cache.StoredDiscoveryItem mStoredDiscoveryItem) {
+        this.mFastPairCacheManager = Locator.get(context, FastPairCacheManager.class);
+        this.mClock = Locator.get(context, Clock.class);
+        this.mStoredDiscoveryItem = mStoredDiscoveryItem;
+    }
+
+    /** @return A new StoredDiscoveryItem with state fields set to their defaults. */
+    public static Cache.StoredDiscoveryItem newStoredDiscoveryItem() {
+        Cache.StoredDiscoveryItem.Builder storedDiscoveryItem =
+                Cache.StoredDiscoveryItem.newBuilder();
+        storedDiscoveryItem.setState(Cache.StoredDiscoveryItem.State.STATE_ENABLED);
+        return storedDiscoveryItem.build();
+    }
+
+    /** @return True if the item is currently nearby. */
+    public boolean isNearby() {
+        return isNearby(mStoredDiscoveryItem, mClock.millis());
+    }
+
+    /**
+     * Checks if the item is Nearby
+     */
+    static boolean isNearby(Cache.StoredDiscoveryItem item, long currentTimeMillis) {
+        // A notification may disappear early, if we get a lost callback from Messages.
+        // But regardless, if we haven't detected the thing in Xmin, consider it gone.
+        return !isLost(item)
+                && !isExpired(
+                currentTimeMillis,
+                item.getLastObservationTimestampMillis());
+    }
+
+    /**
+     * Checks if store discovery item support fast pair or not.
+     */
+    public boolean isFastPair() {
+        Intent intent = parseIntentScheme(mStoredDiscoveryItem.getActionUrl());
+        if (intent == null) {
+            Log.w("FastPairDiscovery", "FastPair: fail to parse action url"
+                    + mStoredDiscoveryItem.getActionUrl());
+            return false;
+        }
+        return ACTION_FAST_PAIR.equals(intent.getAction());
+    }
+
+    /**
+     * Sets the pairing result to done.
+     */
+    public void setPairingProcessDone(boolean success) {
+        setLastUserExperience(success ? Cache.StoredDiscoveryItem.ExperienceType.EXPERIENCE_GOOD
+                : Cache.StoredDiscoveryItem.ExperienceType.EXPERIENCE_BAD);
+        setLostMillis(mClock.millis());
+        Log.d("FastPairDiscovery",
+                "FastPair: set Lost when pairing process done, " + getId());
+    }
+
+    /**
+     * Sets the store discovery item lost time.
+     */
+    public void setLostMillis(long lostMillis) {
+        mStoredDiscoveryItem = mStoredDiscoveryItem.toBuilder().setLostMillis(lostMillis).build();
+
+        mStoredDiscoveryItem = mStoredDiscoveryItem.toBuilder().clearRssi().build();
+
+        mFastPairCacheManager.saveDiscoveryItem(this);
+    }
+
+    /**
+     * Sets the store discovery item mac address.
+     */
+    public void setMacAddress(String address) {
+        mStoredDiscoveryItem = mStoredDiscoveryItem.toBuilder().setMacAddress(address).build();
+
+        mFastPairCacheManager.saveDiscoveryItem(this);
+    }
+
+    /**
+     * Sets the user experience to be good or bad.
+     */
+    public void setLastUserExperience(Cache.StoredDiscoveryItem.ExperienceType experienceType) {
+        mStoredDiscoveryItem = mStoredDiscoveryItem.toBuilder()
+                .setLastUserExperience(experienceType).build();
+        mFastPairCacheManager.saveDiscoveryItem(this);
+    }
+    /**
+     * Gets the user experience to be good or bad.
+     */
+    public Cache.StoredDiscoveryItem.ExperienceType getLastUserExperience() {
+        return mStoredDiscoveryItem.getLastUserExperience();
+    }
+
+    /**
+     * Checks if the item is lost.
+     */
+    private static boolean isLost(Cache.StoredDiscoveryItem item) {
+        return item.getLastObservationTimestampMillis() <= item.getLostMillis();
+    }
+
+    /**
+     * Checks if the item is expired. Expired items are those over getItemExpirationMillis() eg. 2
+     * minutes
+     */
+    public static boolean isExpired(
+            long currentTimestampMillis, @Nullable Long lastObservationTimestampMillis) {
+        if (lastObservationTimestampMillis == null) {
+            return true;
+        }
+        return (currentTimestampMillis - lastObservationTimestampMillis)
+                >= ITEM_EXPIRATION_MILLIS;
+    }
+
+    /**
+     * Checks if the item is deletable for saving disk space. Deletable items are those over
+     * getItemDeletableMillis eg. over 25 hrs.
+     */
+    public static boolean isDeletable(
+            long currentTimestampMillis, @Nullable Long lastObservationTimestampMillis) {
+        if (lastObservationTimestampMillis == null) {
+            return true;
+        }
+        return currentTimestampMillis - lastObservationTimestampMillis
+                >= ITEM_DELETABLE_MILLIS;
+    }
+
+    /** Checks if the item has a pending app install */
+    public boolean isPendingAppInstallValid() {
+        return isPendingAppInstallValid(mClock.millis());
+    }
+
+    /**
+     * Checks if pending app valid.
+     */
+    public boolean isPendingAppInstallValid(long appInstallMillis) {
+        return isPendingAppInstallValid(appInstallMillis, mStoredDiscoveryItem);
+    }
+
+    /**
+     * Checks if the app install time expired.
+     */
+    public static boolean isPendingAppInstallValid(
+            long currentMillis, Cache.StoredDiscoveryItem storedItem) {
+        return currentMillis - storedItem.getPendingAppInstallTimestampMillis()
+                < APP_INSTALL_EXPIRATION_MILLIS;
+    }
+
+
+    /** Checks if the item has enough data to be shown */
+    public boolean isReadyForDisplay() {
+        if (isOffline()) {
+            return true;
+        }
+        boolean hasUrlOrPopularApp = !mStoredDiscoveryItem.getActionUrl().isEmpty();
+
+        return !TextUtils.isEmpty(mStoredDiscoveryItem.getTitle()) && hasUrlOrPopularApp;
+    }
+
+    /** Checks if the item has server error */
+    public boolean isDisabledByServer() {
+        return mStoredDiscoveryItem.getDebugCategory()
+                == Cache.StoredDiscoveryItem.DebugMessageCategory.STATUS_DISABLED_BY_SERVER;
+    }
+
+    private boolean isOffline() {
+        return isOfflineType(getType());
+    }
+
+    /** Checks if the item can be generated on client side. */
+    private static boolean isOfflineType(Cache.NearbyType type) {
+        return type == Cache.NearbyType.NEARBY_CHROMECAST || type == Cache.NearbyType.NEARBY_WEAR;
+    }
+
+    /** Checks if the action url is app install */
+    public boolean isApp() {
+        return mStoredDiscoveryItem.getActionUrlType() == Cache.ResolvedUrlType.APP;
+    }
+
+    /** Checks if it's device item. e.g. Chromecast / Wear */
+    public boolean isDevice() {
+        return isDeviceType(mStoredDiscoveryItem.getType());
+    }
+
+    /** Returns true if an item is muted, or if state is unavailable. */
+    public boolean isMuted() {
+        return mStoredDiscoveryItem.getState() != Cache.StoredDiscoveryItem.State.STATE_ENABLED;
+    }
+
+    /**
+     * Returns the state of store discovery item.
+     */
+    public Cache.StoredDiscoveryItem.State getState() {
+        return mStoredDiscoveryItem.getState();
+    }
+
+    /** Checks if it's device item. e.g. Chromecast / Wear */
+    public static boolean isDeviceType(Cache.NearbyType type) {
+        return type == Cache.NearbyType.NEARBY_CHROMECAST
+                || type == Cache.NearbyType.NEARBY_WEAR
+                || type == Cache.NearbyType.NEARBY_DEVICE;
+    }
+
+    /**
+     * Check if the type is supported.
+     */
+    public static boolean isTypeEnabled(Cache.NearbyType type) {
+        switch (type) {
+            case NEARBY_WEAR:
+            case NEARBY_CHROMECAST:
+            case NEARBY_DEVICE:
+                return true;
+            default:
+                Log.e("FastPairDiscoveryItem", "Invalid item type " + type.name());
+                return false;
+        }
+    }
+
+    /** Gets hash code of UI related data so we can collapse identical items. */
+    public int getUiHashCode() {
+        switch (mStoredDiscoveryItem.getType()) {
+            case NEARBY_CHROMECAST:
+            case NEARBY_WEAR:
+                // For the special-case device types, show one item per type.
+                return Objects.hashCode(mStoredDiscoveryItem.getType());
+            case NEARBY_DEVICE:
+            case NEARBY_TYPE_UNKNOWN:
+            default:
+                return Objects.hash(
+                        mStoredDiscoveryItem.getType(),
+                        mStoredDiscoveryItem.getTitle(),
+                        mStoredDiscoveryItem.getDescription(),
+                        mStoredDiscoveryItem.getAppName(),
+                        mStoredDiscoveryItem.getDisplayUrl(),
+                        mStoredDiscoveryItem.getMacAddress());
+        }
+    }
+
+    // Getters below
+    /**
+     * Returns the id of store discovery item.
+     */
+    @Nullable
+    public String getId() {
+        return mStoredDiscoveryItem.getId();
+    }
+
+    /**
+     * Returns the type of store discovery item.
+     */
+    @Nullable
+    public Cache.NearbyType getType() {
+        return mStoredDiscoveryItem.getType();
+    }
+
+    /**
+     * Returns the title of discovery item.
+     */
+    @Nullable
+    public String getTitle() {
+        return mStoredDiscoveryItem.getTitle();
+    }
+
+    /**
+     * Returns the description of discovery item.
+     */
+    @Nullable
+    public String getDescription() {
+        return mStoredDiscoveryItem.getDescription();
+    }
+
+    /**
+     * Returns the mac address of discovery item.
+     */
+    @Nullable
+    public String getMacAddress() {
+        return mStoredDiscoveryItem.getMacAddress();
+    }
+
+    /**
+     * Returns the display url of discovery item.
+     */
+    @Nullable
+    public String getDisplayUrl() {
+        return mStoredDiscoveryItem.getDisplayUrl();
+    }
+
+    /**
+     * Returns the public key of discovery item.
+     */
+    @Nullable
+    public byte[] getAuthenticationPublicKeySecp256R1() {
+        return mStoredDiscoveryItem.getAuthenticationPublicKeySecp256R1().toByteArray();
+    }
+
+    /**
+     * Returns the pairing secret.
+     */
+    @Nullable
+    public String getFastPairSecretKey() {
+        Intent intent = parseIntentScheme(mStoredDiscoveryItem.getActionUrl());
+        if (intent == null) {
+            Log.d("FastPairDiscoveryItem", "FastPair: fail to parse action url "
+                            + mStoredDiscoveryItem.getActionUrl());
+            return null;
+        }
+        return intent.getStringExtra(EXTRA_FAST_PAIR_SECRET);
+    }
+
+    /**
+     * Returns the fast pair info of discovery item.
+     */
+    @Nullable
+    public Cache.FastPairInformation getFastPairInformation() {
+        return mStoredDiscoveryItem.hasFastPairInformation()
+                ? mStoredDiscoveryItem.getFastPairInformation() : null;
+    }
+
+    /**
+     * Returns the app name of discovery item.
+     */
+    @Nullable
+    private String getAppName() {
+        return mStoredDiscoveryItem.getAppName();
+    }
+
+    /**
+     * Returns the package name of discovery item.
+     */
+    @Nullable
+    public String getAppPackageName() {
+        return mStoredDiscoveryItem.getPackageName();
+    }
+
+
+    /**
+     * Returns the feature graph url of discovery item.
+     */
+    @Nullable
+    private String getHeroImage() {
+        return mStoredDiscoveryItem.getFeatureGraphicUrl();
+    }
+
+    /**
+     * Returns the action url of discovery item.
+     */
+    @Nullable
+    public String getActionUrl() {
+        return mStoredDiscoveryItem.getActionUrl();
+    }
+
+    /**
+     * Returns the rssi value of discovery item.
+     */
+    @Nullable
+    public Integer getRssi() {
+        return mStoredDiscoveryItem.getRssi();
+    }
+
+    /**
+     * Returns the ble record of discovery item.
+     */
+    @Nullable
+    public byte[] getBleRecordBytes() {
+        return mStoredDiscoveryItem.getBleRecordBytes().toByteArray();
+    }
+
+    /**
+     * Returns the TX power of discovery item.
+     */
+    @Nullable
+    public Integer getTxPower() {
+        return mStoredDiscoveryItem.getTxPower();
+    }
+
+    /**
+     * Returns the first observed time stamp of discovery item.
+     */
+    @Nullable
+    public Long getFirstObservationTimestampMillis() {
+        return mStoredDiscoveryItem.getFirstObservationTimestampMillis();
+    }
+
+    /**
+     * Returns the last observed time stamp of discovery item.
+     */
+    @Nullable
+    public Long getLastObservationTimestampMillis() {
+        return mStoredDiscoveryItem.getLastObservationTimestampMillis();
+    }
+
+    /**
+     * Calculates an estimated distance for the item, computed from the TX power (at 1m) and RSSI.
+     *
+     * @return estimated distance, or null if there is no RSSI or no TX power.
+     */
+    @Nullable
+    public Double getEstimatedDistance() {
+        // In the future, we may want to do a foreground subscription to leverage onDistanceChanged.
+        return RangingUtils.distanceFromRssi(mStoredDiscoveryItem.getRssi(),
+                mStoredDiscoveryItem.getTxPower());
+    }
+
+    /**
+     * Gets icon Bitmap from icon store.
+     *
+     * @return null if no icon or icon size is incorrect.
+     */
+    @Nullable
+    public Bitmap getIcon() {
+        Bitmap icon =
+                BitmapFactory.decodeByteArray(
+                        mStoredDiscoveryItem.getIconPng().toByteArray(),
+                        0 /* offset */, mStoredDiscoveryItem.getIconPng().size());
+        if (IconUtils.isIconSizeCorrect(icon)) {
+            return icon;
+        } else {
+            return null;
+        }
+    }
+
+    /** Gets a FIFE URL of the icon. */
+    @Nullable
+    public String getIconFifeUrl() {
+        return mStoredDiscoveryItem.getIconFifeUrl();
+    }
+
+    /**
+     * Gets group id of storedDiscoveryItem.
+     */
+    @Nullable
+    public String getGroupId() {
+        return mStoredDiscoveryItem.getGroupId();
+    }
+
+
+    /**
+     * Compares this object to the specified object: 1. By device type. Device setups are 'greater
+     * than' beacons. 2. By relevance. More relevant items are 'greater than' less relevant items.
+     * 3.By distance. Nearer items are 'greater than' further items.
+     *
+     * <p>In the list view, we sort in descending order, i.e. we put the most relevant items first.
+     */
+    @Override
+    public int compareTo(DiscoveryItem another) {
+        if (getType() != another.getType()) {
+            // For device type v.s. beacon type, rank device item higher.
+            return isDevice() ? 1 : -1;
+        }
+        // For items of the same relevance, compare distance.
+        Double distance1 = getEstimatedDistance();
+        Double distance2 = another.getEstimatedDistance();
+        distance1 = distance1 != null ? distance1 : Double.MAX_VALUE;
+        distance2 = distance2 != null ? distance2 : Double.MAX_VALUE;
+        // Negate because closer items are better ("greater than") further items.
+        return -distance1.compareTo(distance2);
+    }
+
+
+    public Integer getTriggerIdAttachmentTypeHash() {
+        return Objects.hash(mStoredDiscoveryItem.getTriggerId(),
+                mStoredDiscoveryItem.getAttachmentType());
+    }
+
+    @Nullable
+    public String getTriggerId() {
+        return mStoredDiscoveryItem.getTriggerId();
+    }
+
+    @Override
+    public boolean equals(Object another) {
+        if (another instanceof DiscoveryItem) {
+            return ((DiscoveryItem) another).mStoredDiscoveryItem.equals(mStoredDiscoveryItem);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return mStoredDiscoveryItem.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return String.format(
+                "[type=%s], [triggerId=%s], [id=%s], [title=%s], [url=%s], "
+                        + "[ready=%s], [macAddress=%s]",
+                getType().name(),
+                getTriggerId(),
+                getId(),
+                getTitle(),
+                getActionUrl(),
+                isReadyForDisplay(),
+                maskBluetoothAddress(getMacAddress()));
+    }
+
+    /**
+     * Gets a copy of the StoredDiscoveryItem proto backing this DiscoveryItem. Currently needed for
+     * Fast Pair 2.0: We store the item in the cloud associated with a user's account, to enable
+     * pairing with other devices owned by the user.
+     */
+    public Cache.StoredDiscoveryItem getCopyOfStoredItem() {
+        return mStoredDiscoveryItem;
+    }
+
+    /**
+     * Gets the StoredDiscoveryItem represented by this DiscoveryItem. This lets tests manipulate
+     * values that production code should not manipulate.
+     */
+
+    public Cache.StoredDiscoveryItem getStoredItemForTest() {
+        return mStoredDiscoveryItem;
+    }
+
+    /**
+     * Sets the StoredDiscoveryItem represented by this DiscoveryItem. This lets tests manipulate
+     * values that production code should not manipulate.
+     */
+    public void setStoredItemForTest(Cache.StoredDiscoveryItem s) {
+        mStoredDiscoveryItem = s;
+    }
+
+    /**
+     * Parse the intent from item url.
+     */
+    public static Intent parseIntentScheme(String uri) {
+        try {
+            return Intent.parseUri(uri, Intent.URI_INTENT_SCHEME);
+        } catch (URISyntaxException e) {
+            return null;
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/cache/DiscoveryItemContract.java b/nearby/service/java/com/android/server/nearby/fastpair/cache/DiscoveryItemContract.java
new file mode 100644
index 0000000..61ca3fd
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/cache/DiscoveryItemContract.java
@@ -0,0 +1,35 @@
+/*
+ * 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.fastpair.cache;
+
+import android.provider.BaseColumns;
+
+/**
+ * Defines DiscoveryItem database schema.
+ */
+public class DiscoveryItemContract {
+    private DiscoveryItemContract() {}
+
+    /**
+     * Discovery item entry related info.
+     */
+    public static class DiscoveryItemEntry implements BaseColumns {
+        public static final String TABLE_NAME = "SCAN_RESULT";
+        public static final String COLUMN_MODEL_ID = "MODEL_ID";
+        public static final String COLUMN_SCAN_BYTE = "SCAN_RESULT_BYTE";
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/cache/FastPairCacheManager.java b/nearby/service/java/com/android/server/nearby/fastpair/cache/FastPairCacheManager.java
new file mode 100644
index 0000000..b840091
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/cache/FastPairCacheManager.java
@@ -0,0 +1,282 @@
+/*
+ * 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.fastpair.cache;
+
+import android.bluetooth.le.ScanResult;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.util.Log;
+
+import com.android.server.nearby.common.eventloop.Annotations;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import service.proto.Cache;
+import service.proto.Rpcs;
+
+
+/**
+ * Save FastPair device info to database to avoid multiple requesting.
+ */
+public class FastPairCacheManager {
+    private final Context mContext;
+    private final FastPairDbHelper mFastPairDbHelper;
+
+    public FastPairCacheManager(Context context) {
+        mContext = context;
+        mFastPairDbHelper = new FastPairDbHelper(context);
+    }
+
+    /**
+     * Clean up function to release db
+     */
+    public void cleanUp() {
+        mFastPairDbHelper.close();
+    }
+
+    /**
+     * Saves the response to the db
+     */
+    private void saveDevice() {
+    }
+
+    Cache.ServerResponseDbItem getDeviceFromScanResult(ScanResult scanResult) {
+        return Cache.ServerResponseDbItem.newBuilder().build();
+    }
+
+    /**
+     * Checks if the entry can be auto deleted from the cache
+     */
+    public boolean isDeletable(Cache.ServerResponseDbItem entry) {
+        if (!entry.getExpirable()) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Save discovery item into database. Discovery item is item that discovered through Ble before
+     * pairing success.
+     */
+    public boolean saveDiscoveryItem(DiscoveryItem item) {
+
+        SQLiteDatabase db = mFastPairDbHelper.getWritableDatabase();
+        ContentValues values = new ContentValues();
+        values.put(DiscoveryItemContract.DiscoveryItemEntry.COLUMN_MODEL_ID, item.getTriggerId());
+        values.put(DiscoveryItemContract.DiscoveryItemEntry.COLUMN_SCAN_BYTE,
+                item.getCopyOfStoredItem().toByteArray());
+        db.insert(DiscoveryItemContract.DiscoveryItemEntry.TABLE_NAME, null, values);
+        return true;
+    }
+
+
+    @Annotations.EventThread
+    private Rpcs.GetObservedDeviceResponse getObservedDeviceInfo(ScanResult scanResult) {
+        return Rpcs.GetObservedDeviceResponse.getDefaultInstance();
+    }
+
+    /**
+     * Get discovery item from item id.
+     */
+    public DiscoveryItem getDiscoveryItem(String itemId) {
+        return new DiscoveryItem(mContext, getStoredDiscoveryItem(itemId));
+    }
+
+    /**
+     * Get discovery item from item id.
+     */
+    public Cache.StoredDiscoveryItem getStoredDiscoveryItem(String itemId) {
+        SQLiteDatabase db = mFastPairDbHelper.getReadableDatabase();
+        String[] projection = {
+                DiscoveryItemContract.DiscoveryItemEntry.COLUMN_MODEL_ID,
+                DiscoveryItemContract.DiscoveryItemEntry.COLUMN_SCAN_BYTE
+        };
+        String selection = DiscoveryItemContract.DiscoveryItemEntry.COLUMN_MODEL_ID + " =? ";
+        String[] selectionArgs = {itemId};
+        Cursor cursor = db.query(
+                DiscoveryItemContract.DiscoveryItemEntry.TABLE_NAME,
+                projection,
+                selection,
+                selectionArgs,
+                null,
+                null,
+                null
+        );
+
+        if (cursor.moveToNext()) {
+            byte[] res = cursor.getBlob(cursor.getColumnIndexOrThrow(
+                    DiscoveryItemContract.DiscoveryItemEntry.COLUMN_SCAN_BYTE));
+            try {
+                Cache.StoredDiscoveryItem item = Cache.StoredDiscoveryItem.parseFrom(res);
+                return item;
+            } catch (InvalidProtocolBufferException e) {
+                Log.e("FastPairCacheManager", "storediscovery has error");
+            }
+        }
+        cursor.close();
+        return Cache.StoredDiscoveryItem.getDefaultInstance();
+    }
+
+    /**
+     * Get all of the discovery item related info in the cache.
+     */
+    public List<Cache.StoredDiscoveryItem> getAllSavedStoreDiscoveryItem() {
+        List<Cache.StoredDiscoveryItem> storedDiscoveryItemList = new ArrayList<>();
+        SQLiteDatabase db = mFastPairDbHelper.getReadableDatabase();
+        String[] projection = {
+                DiscoveryItemContract.DiscoveryItemEntry.COLUMN_MODEL_ID,
+                DiscoveryItemContract.DiscoveryItemEntry.COLUMN_SCAN_BYTE
+        };
+        Cursor cursor = db.query(
+                DiscoveryItemContract.DiscoveryItemEntry.TABLE_NAME,
+                projection,
+                null,
+                null,
+                null,
+                null,
+                null
+        );
+
+        while (cursor.moveToNext()) {
+            byte[] res = cursor.getBlob(cursor.getColumnIndexOrThrow(
+                    DiscoveryItemContract.DiscoveryItemEntry.COLUMN_SCAN_BYTE));
+            try {
+                Cache.StoredDiscoveryItem item = Cache.StoredDiscoveryItem.parseFrom(res);
+                storedDiscoveryItemList.add(item);
+            } catch (InvalidProtocolBufferException e) {
+                Log.e("FastPairCacheManager", "storediscovery has error");
+            }
+
+        }
+        cursor.close();
+        return storedDiscoveryItemList;
+    }
+
+    /**
+     * Get scan result from local database use model id
+     */
+    public Cache.StoredScanResult getStoredScanResult(String modelId) {
+        return Cache.StoredScanResult.getDefaultInstance();
+    }
+
+    /**
+     * Gets the paired Fast Pair item that paired to the phone through mac address.
+     */
+    public Cache.StoredFastPairItem getStoredFastPairItemFromMacAddress(String macAddress) {
+        SQLiteDatabase db = mFastPairDbHelper.getReadableDatabase();
+        String[] projection = {
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_ACCOUNT_KEY,
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_MAC_ADDRESS,
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_STORED_FAST_PAIR_BYTE
+        };
+        String selection =
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_MAC_ADDRESS + " =? ";
+        String[] selectionArgs = {macAddress};
+        Cursor cursor = db.query(
+                StoredFastPairItemContract.StoredFastPairItemEntry.TABLE_NAME,
+                projection,
+                selection,
+                selectionArgs,
+                null,
+                null,
+                null
+        );
+
+        if (cursor.moveToNext()) {
+            byte[] res = cursor.getBlob(cursor.getColumnIndexOrThrow(
+                    StoredFastPairItemContract.StoredFastPairItemEntry
+                            .COLUMN_STORED_FAST_PAIR_BYTE));
+            try {
+                Cache.StoredFastPairItem item = Cache.StoredFastPairItem.parseFrom(res);
+                return item;
+            } catch (InvalidProtocolBufferException e) {
+                Log.e("FastPairCacheManager", "storediscovery has error");
+            }
+        }
+        cursor.close();
+        return Cache.StoredFastPairItem.getDefaultInstance();
+    }
+
+    /**
+     * Save paired fast pair item into the database.
+     */
+    public boolean putStoredFastPairItem(Cache.StoredFastPairItem storedFastPairItem) {
+        SQLiteDatabase db = mFastPairDbHelper.getWritableDatabase();
+        ContentValues values = new ContentValues();
+        values.put(StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_MAC_ADDRESS,
+                storedFastPairItem.getMacAddress());
+        values.put(StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_ACCOUNT_KEY,
+                storedFastPairItem.getAccountKey().toString());
+        values.put(StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_STORED_FAST_PAIR_BYTE,
+                storedFastPairItem.toByteArray());
+        db.insert(StoredFastPairItemContract.StoredFastPairItemEntry.TABLE_NAME, null, values);
+        return true;
+
+    }
+
+    /**
+     * Removes certain storedFastPairItem so that it can update timely.
+     */
+    public void removeStoredFastPairItem(String macAddress) {
+        SQLiteDatabase db = mFastPairDbHelper.getWritableDatabase();
+        int res = db.delete(StoredFastPairItemContract.StoredFastPairItemEntry.TABLE_NAME,
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_MAC_ADDRESS + "=?",
+                new String[]{macAddress});
+
+    }
+
+    /**
+     * Get all of the store fast pair item related info in the cache.
+     */
+    public List<Cache.StoredFastPairItem> getAllSavedStoredFastPairItem() {
+        List<Cache.StoredFastPairItem> storedFastPairItemList = new ArrayList<>();
+        SQLiteDatabase db = mFastPairDbHelper.getReadableDatabase();
+        String[] projection = {
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_MAC_ADDRESS,
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_ACCOUNT_KEY,
+                StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_STORED_FAST_PAIR_BYTE
+        };
+        Cursor cursor = db.query(
+                StoredFastPairItemContract.StoredFastPairItemEntry.TABLE_NAME,
+                projection,
+                null,
+                null,
+                null,
+                null,
+                null
+        );
+
+        while (cursor.moveToNext()) {
+            byte[] res = cursor.getBlob(cursor.getColumnIndexOrThrow(StoredFastPairItemContract
+                    .StoredFastPairItemEntry.COLUMN_STORED_FAST_PAIR_BYTE));
+            try {
+                Cache.StoredFastPairItem item = Cache.StoredFastPairItem.parseFrom(res);
+                storedFastPairItemList.add(item);
+            } catch (InvalidProtocolBufferException e) {
+                Log.e("FastPairCacheManager", "storediscovery has error");
+            }
+
+        }
+        cursor.close();
+        return storedFastPairItemList;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/cache/FastPairDbHelper.java b/nearby/service/java/com/android/server/nearby/fastpair/cache/FastPairDbHelper.java
new file mode 100644
index 0000000..d950d8d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/cache/FastPairDbHelper.java
@@ -0,0 +1,76 @@
+/*
+ * 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.fastpair.cache;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+/**
+ * Fast Pair db helper handle all of the db actions related Fast Pair.
+ */
+public class FastPairDbHelper extends SQLiteOpenHelper {
+
+    public static final int DATABASE_VERSION = 1;
+    public static final String DATABASE_NAME = "FastPair.db";
+    private static final String SQL_CREATE_DISCOVERY_ITEM_DB =
+            "CREATE TABLE IF NOT EXISTS " + DiscoveryItemContract.DiscoveryItemEntry.TABLE_NAME
+                    + " (" + DiscoveryItemContract.DiscoveryItemEntry._ID
+                    + "INTEGER PRIMARY KEY,"
+                    + DiscoveryItemContract.DiscoveryItemEntry.COLUMN_MODEL_ID
+                    + " TEXT," + DiscoveryItemContract.DiscoveryItemEntry.COLUMN_SCAN_BYTE
+                    + " BLOB)";
+    private static final String SQL_DELETE_DISCOVERY_ITEM_DB =
+            "DROP TABLE IF EXISTS " + DiscoveryItemContract.DiscoveryItemEntry.TABLE_NAME;
+    private static final String SQL_CREATE_FAST_PAIR_ITEM_DB =
+            "CREATE TABLE IF NOT EXISTS "
+                    + StoredFastPairItemContract.StoredFastPairItemEntry.TABLE_NAME
+                    + " (" + StoredFastPairItemContract.StoredFastPairItemEntry._ID
+                    + "INTEGER PRIMARY KEY,"
+                    + StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_MAC_ADDRESS
+                    + " TEXT,"
+                    + StoredFastPairItemContract.StoredFastPairItemEntry.COLUMN_ACCOUNT_KEY
+                    + " TEXT,"
+                    + StoredFastPairItemContract
+                    .StoredFastPairItemEntry.COLUMN_STORED_FAST_PAIR_BYTE
+                    + " BLOB)";
+    private static final String SQL_DELETE_FAST_PAIR_ITEM_DB =
+            "DROP TABLE IF EXISTS " + StoredFastPairItemContract.StoredFastPairItemEntry.TABLE_NAME;
+
+    public FastPairDbHelper(Context context) {
+        super(context, DATABASE_NAME, null, DATABASE_VERSION);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        db.execSQL(SQL_CREATE_DISCOVERY_ITEM_DB);
+        db.execSQL(SQL_CREATE_FAST_PAIR_ITEM_DB);
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        // Since the outdated data has no value so just remove the data.
+        db.execSQL(SQL_DELETE_DISCOVERY_ITEM_DB);
+        db.execSQL(SQL_DELETE_FAST_PAIR_ITEM_DB);
+        onCreate(db);
+    }
+
+    @Override
+    public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        super.onDowngrade(db, oldVersion, newVersion);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/cache/StoredFastPairItemContract.java b/nearby/service/java/com/android/server/nearby/fastpair/cache/StoredFastPairItemContract.java
new file mode 100644
index 0000000..9980565
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/cache/StoredFastPairItemContract.java
@@ -0,0 +1,37 @@
+/*
+ * 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.fastpair.cache;
+
+import android.provider.BaseColumns;
+
+/**
+ * Defines fast pair item database schema.
+ */
+public class StoredFastPairItemContract {
+    private StoredFastPairItemContract() {}
+
+    /**
+     * StoredFastPairItem entry related info.
+     */
+    public static class StoredFastPairItemEntry implements BaseColumns {
+        public static final String TABLE_NAME = "STORED_FAST_PAIR_ITEM";
+        public static final String COLUMN_MAC_ADDRESS = "MAC_ADDRESS";
+        public static final String COLUMN_ACCOUNT_KEY = "ACCOUNT_KEY";
+
+        public static final String COLUMN_STORED_FAST_PAIR_BYTE = "STORED_FAST_PAIR_BYTE";
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/footprint/FastPairUploadInfo.java b/nearby/service/java/com/android/server/nearby/fastpair/footprint/FastPairUploadInfo.java
new file mode 100644
index 0000000..6c9aff0
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/footprint/FastPairUploadInfo.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.fastpair.footprint;
+
+
+import com.google.protobuf.ByteString;
+
+import service.proto.Cache;
+
+/**
+ * Wrapper class that upload the pair info to the footprint.
+ */
+public class FastPairUploadInfo {
+
+    private Cache.StoredDiscoveryItem mStoredDiscoveryItem;
+
+    private ByteString mAccountKey;
+
+    private  ByteString mSha256AccountKeyPublicAddress;
+
+
+    public FastPairUploadInfo(Cache.StoredDiscoveryItem storedDiscoveryItem, ByteString accountKey,
+            ByteString sha256AccountKeyPublicAddress) {
+        mStoredDiscoveryItem = storedDiscoveryItem;
+        mAccountKey = accountKey;
+        mSha256AccountKeyPublicAddress = sha256AccountKeyPublicAddress;
+    }
+
+    public Cache.StoredDiscoveryItem getStoredDiscoveryItem() {
+        return mStoredDiscoveryItem;
+    }
+
+    public ByteString getAccountKey() {
+        return mAccountKey;
+    }
+
+
+    public ByteString getSha256AccountKeyPublicAddress() {
+        return mSha256AccountKeyPublicAddress;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/footprint/FootprintsDeviceManager.java b/nearby/service/java/com/android/server/nearby/fastpair/footprint/FootprintsDeviceManager.java
new file mode 100644
index 0000000..68217c1
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/footprint/FootprintsDeviceManager.java
@@ -0,0 +1,25 @@
+/*
+ * 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.fastpair.footprint;
+
+/**
+ * FootprintDeviceManager is responsible for all of the foot print operation. Footprint will
+ * store all of device info that already paired with certain account. This class will call AOSP
+ * api to let OEM save certain device.
+ */
+public class FootprintsDeviceManager {
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/halfsheet/FastPairHalfSheetManager.java b/nearby/service/java/com/android/server/nearby/fastpair/halfsheet/FastPairHalfSheetManager.java
new file mode 100644
index 0000000..6f79e6e
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/halfsheet/FastPairHalfSheetManager.java
@@ -0,0 +1,214 @@
+/*
+ * 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.fastpair.halfsheet;
+
+import static com.android.server.nearby.fastpair.Constant.DEVICE_PAIRING_FRAGMENT_TYPE;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_BINDER;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_BUNDLE;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_HALF_SHEET_INFO;
+import static com.android.server.nearby.fastpair.Constant.EXTRA_HALF_SHEET_TYPE;
+import static com.android.server.nearby.fastpair.FastPairManager.ACTION_RESOURCES_APK;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.nearby.FastPairDevice;
+import android.nearby.FastPairStatusCallback;
+import android.nearby.PairStatusMetadata;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+import com.android.server.nearby.fastpair.FastPairController;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.util.Environment;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import service.proto.Cache;
+
+/**
+ * Fast Pair ux manager for half sheet.
+ */
+public class FastPairHalfSheetManager {
+    private static final String ACTIVITY_INTENT_ACTION = "android.nearby.SHOW_HALFSHEET";
+    private static final String HALF_SHEET_CLASS_NAME =
+            "com.android.nearby.halfsheet.HalfSheetActivity";
+    private static final String TAG = "FPHalfSheetManager";
+
+    private String mHalfSheetApkPkgName;
+    private final LocatorContextWrapper mLocatorContextWrapper;
+
+    FastPairService mFastPairService;
+
+    public FastPairHalfSheetManager(Context context) {
+        this(new LocatorContextWrapper(context));
+    }
+
+    @VisibleForTesting
+    FastPairHalfSheetManager(LocatorContextWrapper locatorContextWrapper) {
+        mLocatorContextWrapper = locatorContextWrapper;
+        mFastPairService = new FastPairService();
+    }
+
+    /**
+     * Invokes half sheet in the other apk. This function can only be called in Nearby because other
+     * app can't get the correct component name.
+     */
+    public void showHalfSheet(Cache.ScanFastPairStoreItem scanFastPairStoreItem) {
+        try {
+            if (mLocatorContextWrapper != null) {
+                String packageName = getHalfSheetApkPkgName();
+                if (packageName == null) {
+                    Log.e(TAG, "package name is null");
+                    return;
+                }
+                mFastPairService.setFastPairController(
+                        mLocatorContextWrapper.getLocator().get(FastPairController.class));
+                Bundle bundle = new Bundle();
+                bundle.putBinder(EXTRA_BINDER, mFastPairService);
+                mLocatorContextWrapper
+                        .startActivityAsUser(new Intent(ACTIVITY_INTENT_ACTION)
+                                        .putExtra(EXTRA_HALF_SHEET_INFO,
+                                                scanFastPairStoreItem.toByteArray())
+                                        .putExtra(EXTRA_HALF_SHEET_TYPE,
+                                                DEVICE_PAIRING_FRAGMENT_TYPE)
+                                        .putExtra(EXTRA_BUNDLE, bundle)
+                                        .setComponent(new ComponentName(packageName,
+                                                HALF_SHEET_CLASS_NAME)),
+                                UserHandle.CURRENT);
+            }
+        } catch (IllegalStateException e) {
+            Log.e(TAG, "Can't resolve package that contains half sheet");
+        }
+    }
+
+    /**
+     * Shows pairing fail half sheet.
+     */
+    public void showPairingFailed() {
+        FastPairStatusCallback pairStatusCallback = mFastPairService.getPairStatusCallback();
+        if (pairStatusCallback != null) {
+            Log.v(TAG, "showPairingFailed: pairStatusCallback not NULL");
+            pairStatusCallback.onPairUpdate(new FastPairDevice.Builder().build(),
+                    new PairStatusMetadata(PairStatusMetadata.Status.FAIL));
+        } else {
+            Log.w(TAG, "FastPairHalfSheetManager failed to show success half sheet because "
+                    + "the pairStatusCallback is null");
+        }
+    }
+
+    /**
+     * Get the half sheet status whether it is foreground or dismissed
+     */
+    public boolean getHalfSheetForegroundState() {
+        return true;
+    }
+
+    /**
+     * Show passkey confirmation info on half sheet
+     */
+    public void showPasskeyConfirmation(BluetoothDevice device, int passkey) {
+    }
+
+    /**
+     * This function will handle pairing steps for half sheet.
+     */
+    public void showPairingHalfSheet(DiscoveryItem item) {
+        Log.d(TAG, "show pairing half sheet");
+    }
+
+    /**
+     * Shows pairing success info.
+     */
+    public void showPairingSuccessHalfSheet(String address) {
+        FastPairStatusCallback pairStatusCallback = mFastPairService.getPairStatusCallback();
+        if (pairStatusCallback != null) {
+            pairStatusCallback.onPairUpdate(
+                    new FastPairDevice.Builder().setBluetoothAddress(address).build(),
+                    new PairStatusMetadata(PairStatusMetadata.Status.SUCCESS));
+        } else {
+            Log.w(TAG, "FastPairHalfSheetManager failed to show success half sheet because "
+                    + "the pairStatusCallback is null");
+        }
+    }
+
+    /**
+     * Removes dismiss runnable.
+     */
+    public void disableDismissRunnable() {
+    }
+
+    /**
+     * Destroys the bluetooth pairing controller.
+     */
+    public void destroyBluetoothPairController() {
+    }
+
+    /**
+     * Notify manager the pairing has finished.
+     */
+    public void notifyPairingProcessDone(boolean success, String address, DiscoveryItem item) {
+    }
+
+    /**
+     * Gets the package name of HalfSheet.apk
+     * getHalfSheetApkPkgName may invoke PackageManager multiple times and it does not have
+     * race condition check. Since there is no lock for mHalfSheetApkPkgName.
+     */
+    String getHalfSheetApkPkgName() {
+        if (mHalfSheetApkPkgName != null) {
+            return mHalfSheetApkPkgName;
+        }
+        List<ResolveInfo> resolveInfos = mLocatorContextWrapper
+                .getPackageManager().queryIntentActivities(
+                        new Intent(ACTION_RESOURCES_APK),
+                        PackageManager.MATCH_SYSTEM_ONLY);
+
+        // remove apps that don't live in the nearby apex
+        resolveInfos.removeIf(info ->
+                !Environment.isAppInNearbyApex(info.activityInfo.applicationInfo));
+
+        if (resolveInfos.isEmpty()) {
+            // Resource APK not loaded yet, print a stack trace to see where this is called from
+            Log.e("FastPairManager", "Attempted to fetch resources before halfsheet "
+                            + " APK is installed or package manager can't resolve correctly!",
+                    new IllegalStateException());
+            return null;
+        }
+
+        if (resolveInfos.size() > 1) {
+            // multiple apps found, log a warning, but continue
+            Log.w("FastPairManager", "Found > 1 APK that can resolve halfsheet APK intent: "
+                    + resolveInfos.stream()
+                    .map(info -> info.activityInfo.applicationInfo.packageName)
+                    .collect(Collectors.joining(", ")));
+        }
+
+        // Assume the first ResolveInfo is the one we're looking for
+        ResolveInfo info = resolveInfos.get(0);
+        mHalfSheetApkPkgName = info.activityInfo.applicationInfo.packageName;
+        Log.i("FastPairManager", "Found halfsheet APK at: " + mHalfSheetApkPkgName);
+        return mHalfSheetApkPkgName;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/halfsheet/FastPairService.java b/nearby/service/java/com/android/server/nearby/fastpair/halfsheet/FastPairService.java
new file mode 100644
index 0000000..76eabbf
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/halfsheet/FastPairService.java
@@ -0,0 +1,94 @@
+/*
+ * 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.fastpair.halfsheet;
+
+import static com.android.server.nearby.fastpair.Constant.TAG;
+
+import android.nearby.FastPairDevice;
+import android.nearby.FastPairStatusCallback;
+import android.nearby.PairStatusMetadata;
+import android.nearby.aidl.IFastPairClient;
+import android.nearby.aidl.IFastPairStatusCallback;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.server.nearby.fastpair.FastPairController;
+
+/**
+ * Service implementing Fast Pair functionality.
+ *
+ * @hide
+ */
+public class FastPairService extends IFastPairClient.Stub {
+
+    private IBinder mStatusCallbackProxy;
+    private FastPairController mFastPairController;
+    private FastPairStatusCallback mFastPairStatusCallback;
+
+    /**
+     * Registers the Binder call back in the server notifies the proxy when there is an update
+     * in the server.
+     */
+    @Override
+    public void registerHalfSheet(IFastPairStatusCallback iFastPairStatusCallback) {
+        mStatusCallbackProxy = iFastPairStatusCallback.asBinder();
+        mFastPairStatusCallback = new FastPairStatusCallback() {
+            @Override
+            public void onPairUpdate(FastPairDevice fastPairDevice,
+                    PairStatusMetadata pairStatusMetadata) {
+                try {
+                    iFastPairStatusCallback.onPairUpdate(fastPairDevice, pairStatusMetadata);
+                } catch (RemoteException e) {
+                    Log.w(TAG, "Failed to update pair status.", e);
+                }
+            }
+        };
+    }
+
+    /**
+     * Unregisters the Binder call back in the server.
+     */
+    @Override
+    public void unregisterHalfSheet(IFastPairStatusCallback iFastPairStatusCallback) {
+        mStatusCallbackProxy = null;
+        mFastPairStatusCallback = null;
+    }
+
+    /**
+     * Asks the Fast Pair service to pair the device. initial pairing.
+     */
+    @Override
+    public void connect(FastPairDevice fastPairDevice) {
+        if (mFastPairController != null) {
+            mFastPairController.pair(fastPairDevice);
+        } else {
+            Log.w(TAG, "Failed to connect because there is no FastPairController.");
+        }
+    }
+
+    public FastPairStatusCallback getPairStatusCallback() {
+        return mFastPairStatusCallback;
+    }
+
+    /**
+     * Sets function for Fast Pair controller.
+     */
+    public void setFastPairController(FastPairController fastPairController) {
+        mFastPairController = fastPairController;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/notification/FastPairNotificationManager.java b/nearby/service/java/com/android/server/nearby/fastpair/notification/FastPairNotificationManager.java
new file mode 100644
index 0000000..b1ae573
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/notification/FastPairNotificationManager.java
@@ -0,0 +1,71 @@
+/*
+ * 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.fastpair.notification;
+
+
+import android.annotation.Nullable;
+import android.content.Context;
+
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+
+/**
+ * Responsible for show notification logic.
+ */
+public class FastPairNotificationManager {
+
+    /**
+     * FastPair notification manager that handle notification ui for fast pair.
+     */
+    public FastPairNotificationManager(Context context, DiscoveryItem item, boolean useLargeIcon,
+            int notificationId) {
+    }
+    /**
+     * FastPair notification manager that handle notification ui for fast pair.
+     */
+    public FastPairNotificationManager(Context context, DiscoveryItem item, boolean useLargeIcon) {
+
+    }
+
+    /**
+     * Shows pairing in progress notification.
+     */
+    public void showConnectingNotification() {}
+
+    /**
+     * Shows success notification
+     */
+    public void showPairingSucceededNotification(
+            @Nullable String companionApp,
+            int batteryLevel,
+            @Nullable String deviceName,
+            String address) {
+
+    }
+
+    /**
+     * Shows failed notification.
+     */
+    public void showPairingFailedNotification(byte[] accountKey) {
+
+    }
+
+    /**
+     * Notify the pairing process is done.
+     */
+    public void notifyPairingProcessDone(boolean success, boolean forceNotify,
+            String privateAddress, String publicAddress) {}
+}
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/HalfSheetPairingProgressHandler.java b/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/HalfSheetPairingProgressHandler.java
new file mode 100644
index 0000000..c95f74f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/HalfSheetPairingProgressHandler.java
@@ -0,0 +1,112 @@
+/*
+ * 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.fastpair.pairinghandler;
+
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+
+import com.android.server.nearby.common.bluetooth.fastpair.FastPairConnection;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+import com.android.server.nearby.intdefs.NearbyEventIntDefs;
+
+/** Pairing progress handler that handle pairing come from half sheet. */
+public final class HalfSheetPairingProgressHandler extends PairingProgressHandlerBase {
+
+    private final FastPairHalfSheetManager mFastPairHalfSheetManager;
+    private final boolean mIsSubsequentPair;
+    private final DiscoveryItem mItemResurface;
+
+    HalfSheetPairingProgressHandler(
+            Context context,
+            DiscoveryItem item,
+            @Nullable String companionApp,
+            @Nullable byte[] accountKey) {
+        super(context, item);
+        this.mFastPairHalfSheetManager = Locator.get(context, FastPairHalfSheetManager.class);
+        this.mIsSubsequentPair =
+                item.getAuthenticationPublicKeySecp256R1() != null && accountKey != null;
+        this.mItemResurface = item;
+    }
+
+    @Override
+    protected int getPairStartEventCode() {
+        return mIsSubsequentPair ? NearbyEventIntDefs.EventCode.SUBSEQUENT_PAIR_START
+                : NearbyEventIntDefs.EventCode.MAGIC_PAIR_START;
+    }
+
+    @Override
+    protected int getPairEndEventCode() {
+        return mIsSubsequentPair ? NearbyEventIntDefs.EventCode.SUBSEQUENT_PAIR_END
+                : NearbyEventIntDefs.EventCode.MAGIC_PAIR_END;
+    }
+
+    @Override
+    public void onPairingStarted() {
+        super.onPairingStarted();
+        // Half sheet is not in the foreground reshow half sheet, also avoid showing HalfSheet on TV
+        if (!mFastPairHalfSheetManager.getHalfSheetForegroundState()) {
+            mFastPairHalfSheetManager.showPairingHalfSheet(mItemResurface);
+        }
+        mFastPairHalfSheetManager.disableDismissRunnable();
+    }
+
+    @Override
+    public void onHandlePasskeyConfirmation(BluetoothDevice device, int passkey) {
+        super.onHandlePasskeyConfirmation(device, passkey);
+        mFastPairHalfSheetManager.showPasskeyConfirmation(device, passkey);
+    }
+
+    @Nullable
+    @Override
+    public String onPairedCallbackCalled(
+            FastPairConnection connection,
+            byte[] accountKey,
+            FootprintsDeviceManager footprints,
+            String address) {
+        String deviceName = super.onPairedCallbackCalled(connection, accountKey,
+                footprints, address);
+        mFastPairHalfSheetManager.showPairingSuccessHalfSheet(address);
+        mFastPairHalfSheetManager.disableDismissRunnable();
+        return deviceName;
+    }
+
+    @Override
+    public void onPairingFailed(Throwable throwable) {
+        super.onPairingFailed(throwable);
+        mFastPairHalfSheetManager.disableDismissRunnable();
+        mFastPairHalfSheetManager.showPairingFailed();
+        mFastPairHalfSheetManager.notifyPairingProcessDone(
+                /* success= */ false, /* publicAddress= */ null, mItem);
+        // fix auto rebond issue
+        mFastPairHalfSheetManager.destroyBluetoothPairController();
+    }
+
+    @Override
+    public void onPairingSuccess(String address) {
+        super.onPairingSuccess(address);
+        mFastPairHalfSheetManager.disableDismissRunnable();
+        mFastPairHalfSheetManager
+                .notifyPairingProcessDone(/* success= */ true, address, mItem);
+        mFastPairHalfSheetManager.destroyBluetoothPairController();
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/NotificationPairingProgressHandler.java b/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/NotificationPairingProgressHandler.java
new file mode 100644
index 0000000..d469c45
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/NotificationPairingProgressHandler.java
@@ -0,0 +1,125 @@
+/*
+ * 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.fastpair.pairinghandler;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.server.nearby.common.bluetooth.fastpair.FastPairConnection;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.notification.FastPairNotificationManager;
+import com.android.server.nearby.intdefs.NearbyEventIntDefs;
+
+/** Pairing progress handler for pairing coming from notifications. */
+@SuppressWarnings("nullness")
+public class NotificationPairingProgressHandler extends PairingProgressHandlerBase {
+    private final FastPairNotificationManager mFastPairNotificationManager;
+    @Nullable
+    private final String mCompanionApp;
+    @Nullable
+    private final byte[] mAccountKey;
+    private final boolean mIsSubsequentPair;
+
+    NotificationPairingProgressHandler(
+            Context context,
+            DiscoveryItem item,
+            @Nullable String companionApp,
+            @Nullable byte[] accountKey,
+            FastPairNotificationManager mFastPairNotificationManager) {
+        super(context, item);
+        this.mFastPairNotificationManager = mFastPairNotificationManager;
+        this.mCompanionApp = companionApp;
+        this.mAccountKey = accountKey;
+        this.mIsSubsequentPair =
+                item.getAuthenticationPublicKeySecp256R1() != null && accountKey != null;
+    }
+
+    @Override
+    public int getPairStartEventCode() {
+        return mIsSubsequentPair ? NearbyEventIntDefs.EventCode.SUBSEQUENT_PAIR_START
+                : NearbyEventIntDefs.EventCode.MAGIC_PAIR_START;
+    }
+
+    @Override
+    public int getPairEndEventCode() {
+        return mIsSubsequentPair ? NearbyEventIntDefs.EventCode.SUBSEQUENT_PAIR_END
+                : NearbyEventIntDefs.EventCode.MAGIC_PAIR_END;
+    }
+
+    @Override
+    public void onReadyToPair() {
+        super.onReadyToPair();
+        mFastPairNotificationManager.showConnectingNotification();
+    }
+
+    @Override
+    public String onPairedCallbackCalled(
+            FastPairConnection connection,
+            byte[] accountKey,
+            FootprintsDeviceManager footprints,
+            String address) {
+        String deviceName = super.onPairedCallbackCalled(connection, accountKey, footprints,
+                address);
+
+        int batteryLevel = -1;
+
+        BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
+        BluetoothAdapter bluetoothAdapter = bluetoothManager.getAdapter();
+        if (bluetoothAdapter != null) {
+            // Need to check battery level here set that to -1 for now
+            batteryLevel = -1;
+        } else {
+            Log.v(
+                    "NotificationPairingProgressHandler",
+                    "onPairedCallbackCalled getBatteryLevel failed,"
+                            + " adapter is null");
+        }
+        mFastPairNotificationManager.showPairingSucceededNotification(
+                !TextUtils.isEmpty(mCompanionApp) ? mCompanionApp : null,
+                batteryLevel,
+                deviceName,
+                address);
+        return deviceName;
+    }
+
+    @Override
+    public void onPairingFailed(Throwable throwable) {
+        super.onPairingFailed(throwable);
+        mFastPairNotificationManager.showPairingFailedNotification(mAccountKey);
+        mFastPairNotificationManager.notifyPairingProcessDone(
+                /* success= */ false,
+                /* forceNotify= */ false,
+                /* privateAddress= */ mItem.getMacAddress(),
+                /* publicAddress= */ null);
+    }
+
+    @Override
+    public void onPairingSuccess(String address) {
+        super.onPairingSuccess(address);
+        mFastPairNotificationManager.notifyPairingProcessDone(
+                /* success= */ true,
+                /* forceNotify= */ false,
+                /* privateAddress= */ mItem.getMacAddress(),
+                /* publicAddress= */ address);
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/PairingProgressHandlerBase.java b/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/PairingProgressHandlerBase.java
new file mode 100644
index 0000000..ccd7e5e
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/fastpair/pairinghandler/PairingProgressHandlerBase.java
@@ -0,0 +1,208 @@
+/*
+ * 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.fastpair.pairinghandler;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+import static com.android.server.nearby.fastpair.FastPairManager.isThroughFastPair2InitialPairing;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.util.Log;
+
+import com.android.server.nearby.common.bluetooth.fastpair.FastPairConnection;
+import com.android.server.nearby.common.bluetooth.fastpair.Preferences;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+import com.android.server.nearby.fastpair.notification.FastPairNotificationManager;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs;
+
+/** Base class for pairing progress handler. */
+public abstract class PairingProgressHandlerBase {
+    protected final Context mContext;
+    protected final DiscoveryItem mItem;
+    @Nullable
+    private FastPairEventIntDefs.ErrorCode mRescueFromError;
+
+    protected abstract int getPairStartEventCode();
+
+    protected abstract int getPairEndEventCode();
+
+    protected PairingProgressHandlerBase(Context context, DiscoveryItem item) {
+        this.mContext = context;
+        this.mItem = item;
+    }
+
+
+    /**
+     * Pairing progress init function.
+     */
+    public static PairingProgressHandlerBase create(
+            Context context,
+            DiscoveryItem item,
+            @Nullable String companionApp,
+            @Nullable byte[] accountKey,
+            FootprintsDeviceManager footprints,
+            FastPairNotificationManager notificationManager,
+            FastPairHalfSheetManager fastPairHalfSheetManager,
+            boolean isRetroactivePair) {
+        PairingProgressHandlerBase pairingProgressHandlerBase;
+        // Disable half sheet on subsequent pairing
+        if (item.getAuthenticationPublicKeySecp256R1() != null
+                && accountKey != null) {
+            // Subsequent pairing
+            pairingProgressHandlerBase =
+                    new NotificationPairingProgressHandler(
+                            context, item, companionApp, accountKey, notificationManager);
+        } else {
+            pairingProgressHandlerBase =
+                    new HalfSheetPairingProgressHandler(context, item, companionApp, accountKey);
+        }
+
+
+        Log.v("PairingHandler",
+                "PairingProgressHandler:Create "
+                        + item.getMacAddress() + " for pairing");
+        return pairingProgressHandlerBase;
+    }
+
+
+    /**
+     * Function calls when pairing start.
+     */
+    public void onPairingStarted() {
+        Log.v("PairingHandler", "PairingProgressHandler:onPairingStarted");
+    }
+
+    /**
+     * Waits for screen to unlock.
+     */
+    public void onWaitForScreenUnlock() {
+        Log.v("PairingHandler", "PairingProgressHandler:onWaitForScreenUnlock");
+    }
+
+    /**
+     * Function calls when screen unlock.
+     */
+    public void onScreenUnlocked() {
+        Log.v("PairingHandler", "PairingProgressHandler:onScreenUnlocked");
+    }
+
+    /**
+     * Calls when the handler is ready to pair.
+     */
+    public void onReadyToPair() {
+        Log.v("PairingHandler", "PairingProgressHandler:onReadyToPair");
+    }
+
+    /**
+     * Helps to set up pairing preference.
+     */
+    public void onSetupPreferencesBuilder(Preferences.Builder builder) {
+        Log.v("PairingHandler", "PairingProgressHandler:onSetupPreferencesBuilder");
+    }
+
+    /**
+     * Calls when pairing setup complete.
+     */
+    public void onPairingSetupCompleted() {
+        Log.v("PairingHandler", "PairingProgressHandler:onPairingSetupCompleted");
+    }
+
+    /** Called while pairing if needs to handle the passkey confirmation by Ui. */
+    public void onHandlePasskeyConfirmation(BluetoothDevice device, int passkey) {
+        Log.v("PairingHandler", "PairingProgressHandler:onHandlePasskeyConfirmation");
+    }
+
+    /**
+     * In this callback, we know if it is a real initial pairing by existing account key, and do
+     * following things:
+     * <li>1, optIn footprint for initial pairing.
+     * <li>2, write the device name to provider
+     * <li>2.1, generate default personalized name for initial pairing or get the personalized name
+     * from footprint for subsequent pairing.
+     * <li>2.2, set alias name for the bluetooth device.
+     * <li>2.3, update the device name for connection to write into provider for initial pair.
+     * <li>3, suppress battery notifications until oobe finishes.
+     *
+     * @return display name of the pairing device
+     */
+    @Nullable
+    public String onPairedCallbackCalled(
+            FastPairConnection connection,
+            byte[] accountKey,
+            FootprintsDeviceManager footprints,
+            String address) {
+        Log.v("PairingHandler",
+                "PairingProgressHandler:onPairedCallbackCalled with address: "
+                        + address);
+
+        byte[] existingAccountKey = connection.getExistingAccountKey();
+        optInFootprintsForInitialPairing(footprints, mItem, accountKey, existingAccountKey);
+        // Add support for naming the device
+        return null;
+    }
+
+    /**
+     * Gets the related info from db use account key.
+     */
+    @Nullable
+    public byte[] getKeyForLocalCache(
+            byte[] accountKey, FastPairConnection connection,
+            FastPairConnection.SharedSecret sharedSecret) {
+        Log.v("PairingHandler", "PairingProgressHandler:getKeyForLocalCache");
+        return accountKey != null ? accountKey : connection.getExistingAccountKey();
+    }
+
+    /**
+     * Function handles pairing fail.
+     */
+    public void onPairingFailed(Throwable throwable) {
+        Log.w("PairingHandler", "PairingProgressHandler:onPairingFailed");
+    }
+
+    /**
+     * Function handles pairing success.
+     */
+    public void onPairingSuccess(String address) {
+        Log.v("PairingHandler", "PairingProgressHandler:onPairingSuccess with address: "
+                + maskBluetoothAddress(address));
+    }
+
+    private static void optInFootprintsForInitialPairing(
+            FootprintsDeviceManager footprints,
+            DiscoveryItem item,
+            byte[] accountKey,
+            @Nullable byte[] existingAccountKey) {
+        if (isThroughFastPair2InitialPairing(item, accountKey) && existingAccountKey == null) {
+            // enable the save to footprint
+            Log.v("PairingHandler", "footprint should call opt in here");
+        }
+    }
+
+    /**
+     * Returns {@code true} if the PairingProgressHandler is running at the background.
+     *
+     * <p>In order to keep the following status notification shows as a heads up, we must wait for
+     * the screen unlocked to continue.
+     */
+    public boolean skipWaitingScreenUnlock() {
+        return false;
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/injector/ContextHubManagerAdapter.java b/nearby/service/java/com/android/server/nearby/injector/ContextHubManagerAdapter.java
new file mode 100644
index 0000000..9af0227
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/injector/ContextHubManagerAdapter.java
@@ -0,0 +1,77 @@
+/*
+ * 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.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.NanoAppState;
+
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/** Wrap {@link ContextHubManager} for dependence injection. */
+public class ContextHubManagerAdapter {
+    private final ContextHubManager mManager;
+
+    public ContextHubManagerAdapter(ContextHubManager manager) {
+        mManager = manager;
+    }
+
+    /**
+     * Returns the list of ContextHubInfo objects describing the available Context Hubs.
+     *
+     * @return the list of ContextHubInfo objects
+     * @see ContextHubInfo
+     */
+    public List<ContextHubInfo> getContextHubs() {
+        return mManager.getContextHubs();
+    }
+
+    /**
+     * Requests a query for nanoapps loaded at the specified Context Hub.
+     *
+     * @param hubInfo the hub to query a list of nanoapps from
+     * @return the ContextHubTransaction of the request
+     * @throws NullPointerException if hubInfo is null
+     */
+    public ContextHubTransaction<List<NanoAppState>> queryNanoApps(ContextHubInfo hubInfo) {
+        return mManager.queryNanoApps(hubInfo);
+    }
+
+    /**
+     * Creates and registers a client and its callback with the Context Hub Service.
+     *
+     * <p>A client is registered with the Context Hub Service for a specified Context Hub. When the
+     * registration succeeds, the client can send messages to nanoapps through the returned {@link
+     * ContextHubClient} object, and receive notifications through the provided callback.
+     *
+     * @param hubInfo the hub to attach this client to
+     * @param executor the executor to invoke the callback
+     * @param callback the notification callback to register
+     * @return the registered client object
+     * @throws IllegalArgumentException if hubInfo does not represent a valid hub
+     * @throws IllegalStateException if there were too many registered clients at the service
+     * @throws NullPointerException if callback, hubInfo, or executor is null
+     */
+    public ContextHubClient createClient(
+            ContextHubInfo hubInfo, ContextHubClientCallback callback, Executor executor) {
+        return mManager.createClient(hubInfo, callback, executor);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/injector/Injector.java b/nearby/service/java/com/android/server/nearby/injector/Injector.java
new file mode 100644
index 0000000..f990dc9
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/injector/Injector.java
@@ -0,0 +1,32 @@
+/*
+ * 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.injector;
+
+import android.bluetooth.BluetoothAdapter;
+
+/**
+ * Nearby dependency injector. To be used for accessing various Nearby class instances and as a
+ * handle for mock injection.
+ */
+public interface Injector {
+
+    /** Get the BluetoothAdapter for BleDiscoveryProvider to scan. */
+    BluetoothAdapter getBluetoothAdapter();
+
+    /** Get the ContextHubManagerAdapter for ChreDiscoveryProvider to scan. */
+    ContextHubManagerAdapter getContextHubManagerAdapter();
+}
diff --git a/nearby/service/java/com/android/server/nearby/intdefs/FastPairEventIntDefs.java b/nearby/service/java/com/android/server/nearby/intdefs/FastPairEventIntDefs.java
new file mode 100644
index 0000000..8bb7980
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/intdefs/FastPairEventIntDefs.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright 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.intdefs;
+
+import androidx.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** Holds integer definitions for FastPair. */
+public class FastPairEventIntDefs {
+
+    /** Fast Pair Bond State. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    BondState.UNKNOWN_BOND_STATE,
+                    BondState.NONE,
+                    BondState.BONDING,
+                    BondState.BONDED,
+            })
+    public @interface BondState {
+        int UNKNOWN_BOND_STATE = 0;
+        int NONE = 10;
+        int BONDING = 11;
+        int BONDED = 12;
+    }
+
+    /** Fast Pair error code. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    ErrorCode.UNKNOWN_ERROR_CODE,
+                    ErrorCode.OTHER_ERROR,
+                    ErrorCode.TIMEOUT,
+                    ErrorCode.INTERRUPTED,
+                    ErrorCode.REFLECTIVE_OPERATION_EXCEPTION,
+                    ErrorCode.EXECUTION_EXCEPTION,
+                    ErrorCode.PARSE_EXCEPTION,
+                    ErrorCode.MDH_REMOTE_EXCEPTION,
+                    ErrorCode.SUCCESS_RETRY_GATT_ERROR,
+                    ErrorCode.SUCCESS_RETRY_GATT_TIMEOUT,
+                    ErrorCode.SUCCESS_RETRY_SECRET_HANDSHAKE_ERROR,
+                    ErrorCode.SUCCESS_RETRY_SECRET_HANDSHAKE_TIMEOUT,
+                    ErrorCode.SUCCESS_SECRET_HANDSHAKE_RECONNECT,
+                    ErrorCode.SUCCESS_ADDRESS_ROTATE,
+                    ErrorCode.SUCCESS_SIGNAL_LOST,
+            })
+    public @interface ErrorCode {
+        int UNKNOWN_ERROR_CODE = 0;
+
+        // Check the other fields for a more specific error code.
+        int OTHER_ERROR = 1;
+
+        // The operation timed out.
+        int TIMEOUT = 2;
+
+        // The thread was interrupted.
+        int INTERRUPTED = 3;
+
+        // Some reflective call failed (should never happen).
+        int REFLECTIVE_OPERATION_EXCEPTION = 4;
+
+        // A Future threw an exception (should never happen).
+        int EXECUTION_EXCEPTION = 5;
+
+        // Parsing something (e.g. BR/EDR Handover data) failed.
+        int PARSE_EXCEPTION = 6;
+
+        // A failure at MDH.
+        int MDH_REMOTE_EXCEPTION = 7;
+
+        // For errors on GATT connection and retry success
+        int SUCCESS_RETRY_GATT_ERROR = 8;
+
+        // For timeout on GATT connection and retry success
+        int SUCCESS_RETRY_GATT_TIMEOUT = 9;
+
+        // For errors on secret handshake and retry success
+        int SUCCESS_RETRY_SECRET_HANDSHAKE_ERROR = 10;
+
+        // For timeout on secret handshake and retry success
+        int SUCCESS_RETRY_SECRET_HANDSHAKE_TIMEOUT = 11;
+
+        // For secret handshake fail and restart GATT connection success
+        int SUCCESS_SECRET_HANDSHAKE_RECONNECT = 12;
+
+        // For address rotate and retry with new address success
+        int SUCCESS_ADDRESS_ROTATE = 13;
+
+        // For signal lost and retry with old address still success
+        int SUCCESS_SIGNAL_LOST = 14;
+    }
+
+    /** Fast Pair BrEdrHandover Error Code. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    BrEdrHandoverErrorCode.UNKNOWN_BR_EDR_HANDOVER_ERROR_CODE,
+                    BrEdrHandoverErrorCode.CONTROL_POINT_RESULT_CODE_NOT_SUCCESS,
+                    BrEdrHandoverErrorCode.BLUETOOTH_MAC_INVALID,
+                    BrEdrHandoverErrorCode.TRANSPORT_BLOCK_INVALID,
+            })
+    public @interface BrEdrHandoverErrorCode {
+        int UNKNOWN_BR_EDR_HANDOVER_ERROR_CODE = 0;
+        int CONTROL_POINT_RESULT_CODE_NOT_SUCCESS = 1;
+        int BLUETOOTH_MAC_INVALID = 2;
+        int TRANSPORT_BLOCK_INVALID = 3;
+    }
+
+    /** Fast Pair CreateBound Error Code. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    CreateBondErrorCode.UNKNOWN_BOND_ERROR_CODE,
+                    CreateBondErrorCode.BOND_BROKEN,
+                    CreateBondErrorCode.POSSIBLE_MITM,
+                    CreateBondErrorCode.NO_PERMISSION,
+                    CreateBondErrorCode.INCORRECT_VARIANT,
+                    CreateBondErrorCode.FAILED_BUT_ALREADY_RECEIVE_PASS_KEY,
+            })
+    public @interface CreateBondErrorCode {
+        int UNKNOWN_BOND_ERROR_CODE = 0;
+        int BOND_BROKEN = 1;
+        int POSSIBLE_MITM = 2;
+        int NO_PERMISSION = 3;
+        int INCORRECT_VARIANT = 4;
+        int FAILED_BUT_ALREADY_RECEIVE_PASS_KEY = 5;
+    }
+
+    /** Fast Pair Connect Error Code. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    ConnectErrorCode.UNKNOWN_CONNECT_ERROR_CODE,
+                    ConnectErrorCode.UNSUPPORTED_PROFILE,
+                    ConnectErrorCode.GET_PROFILE_PROXY_FAILED,
+                    ConnectErrorCode.DISCONNECTED,
+                    ConnectErrorCode.LINK_KEY_CLEARED,
+                    ConnectErrorCode.FAIL_TO_DISCOVERY,
+                    ConnectErrorCode.DISCOVERY_NOT_FINISHED,
+            })
+    public @interface ConnectErrorCode {
+        int UNKNOWN_CONNECT_ERROR_CODE = 0;
+        int UNSUPPORTED_PROFILE = 1;
+        int GET_PROFILE_PROXY_FAILED = 2;
+        int DISCONNECTED = 3;
+        int LINK_KEY_CLEARED = 4;
+        int FAIL_TO_DISCOVERY = 5;
+        int DISCOVERY_NOT_FINISHED = 6;
+    }
+
+    private FastPairEventIntDefs() {}
+}
diff --git a/nearby/service/java/com/android/server/nearby/intdefs/NearbyEventIntDefs.java b/nearby/service/java/com/android/server/nearby/intdefs/NearbyEventIntDefs.java
new file mode 100644
index 0000000..91bf49a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/intdefs/NearbyEventIntDefs.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 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.intdefs;
+
+import androidx.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** Holds integer definitions for NearbyEvent. */
+public class NearbyEventIntDefs {
+
+    /** NearbyEvent Code. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    EventCode.UNKNOWN_EVENT_TYPE,
+                    EventCode.MAGIC_PAIR_START,
+                    EventCode.WAIT_FOR_SCREEN_UNLOCK,
+                    EventCode.GATT_CONNECT,
+                    EventCode.BR_EDR_HANDOVER_WRITE_CONTROL_POINT_REQUEST,
+                    EventCode.BR_EDR_HANDOVER_READ_BLUETOOTH_MAC,
+                    EventCode.BR_EDR_HANDOVER_READ_TRANSPORT_BLOCK,
+                    EventCode.GET_PROFILES_VIA_SDP,
+                    EventCode.DISCOVER_DEVICE,
+                    EventCode.CANCEL_DISCOVERY,
+                    EventCode.REMOVE_BOND,
+                    EventCode.CANCEL_BOND,
+                    EventCode.CREATE_BOND,
+                    EventCode.CONNECT_PROFILE,
+                    EventCode.DISABLE_BLUETOOTH,
+                    EventCode.ENABLE_BLUETOOTH,
+                    EventCode.MAGIC_PAIR_END,
+                    EventCode.SECRET_HANDSHAKE,
+                    EventCode.WRITE_ACCOUNT_KEY,
+                    EventCode.WRITE_TO_FOOTPRINTS,
+                    EventCode.PASSKEY_EXCHANGE,
+                    EventCode.DEVICE_RECOGNIZED,
+                    EventCode.GET_LOCAL_PUBLIC_ADDRESS,
+                    EventCode.DIRECTLY_CONNECTED_TO_PROFILE,
+                    EventCode.DEVICE_ALIAS_CHANGED,
+                    EventCode.WRITE_DEVICE_NAME,
+                    EventCode.UPDATE_PROVIDER_NAME_START,
+                    EventCode.UPDATE_PROVIDER_NAME_END,
+                    EventCode.READ_FIRMWARE_VERSION,
+                    EventCode.RETROACTIVE_PAIR_START,
+                    EventCode.RETROACTIVE_PAIR_END,
+                    EventCode.SUBSEQUENT_PAIR_START,
+                    EventCode.SUBSEQUENT_PAIR_END,
+                    EventCode.BISTO_PAIR_START,
+                    EventCode.BISTO_PAIR_END,
+                    EventCode.REMOTE_PAIR_START,
+                    EventCode.REMOTE_PAIR_END,
+                    EventCode.BEFORE_CREATE_BOND,
+                    EventCode.BEFORE_CREATE_BOND_BONDING,
+                    EventCode.BEFORE_CREATE_BOND_BONDED,
+                    EventCode.BEFORE_CONNECT_PROFILE,
+                    EventCode.HANDLE_PAIRING_REQUEST,
+                    EventCode.SECRET_HANDSHAKE_GATT_COMMUNICATION,
+                    EventCode.GATT_CONNECTION_AND_SECRET_HANDSHAKE,
+                    EventCode.CHECK_SIGNAL_AFTER_HANDSHAKE,
+                    EventCode.RECOVER_BY_RETRY_GATT,
+                    EventCode.RECOVER_BY_RETRY_HANDSHAKE,
+                    EventCode.RECOVER_BY_RETRY_HANDSHAKE_RECONNECT,
+                    EventCode.GATT_HANDSHAKE_MANUAL_RETRY_ATTEMPTS,
+                    EventCode.PAIR_WITH_CACHED_MODEL_ID,
+                    EventCode.DIRECTLY_CONNECT_PROFILE_WITH_CACHED_ADDRESS,
+                    EventCode.PAIR_WITH_NEW_MODEL,
+            })
+    public @interface EventCode {
+        int UNKNOWN_EVENT_TYPE = 0;
+
+        // Codes for Magic Pair.
+        // Starting at 1000 to not conflict with other existing codes (e.g.
+        // DiscoveryEvent) that may be migrated to become official Event Codes.
+        int MAGIC_PAIR_START = 1010;
+        int WAIT_FOR_SCREEN_UNLOCK = 1020;
+        int GATT_CONNECT = 1030;
+        int BR_EDR_HANDOVER_WRITE_CONTROL_POINT_REQUEST = 1040;
+        int BR_EDR_HANDOVER_READ_BLUETOOTH_MAC = 1050;
+        int BR_EDR_HANDOVER_READ_TRANSPORT_BLOCK = 1060;
+        int GET_PROFILES_VIA_SDP = 1070;
+        int DISCOVER_DEVICE = 1080;
+        int CANCEL_DISCOVERY = 1090;
+        int REMOVE_BOND = 1100;
+        int CANCEL_BOND = 1110;
+        int CREATE_BOND = 1120;
+        int CONNECT_PROFILE = 1130;
+        int DISABLE_BLUETOOTH = 1140;
+        int ENABLE_BLUETOOTH = 1150;
+        int MAGIC_PAIR_END = 1160;
+        int SECRET_HANDSHAKE = 1170;
+        int WRITE_ACCOUNT_KEY = 1180;
+        int WRITE_TO_FOOTPRINTS = 1190;
+        int PASSKEY_EXCHANGE = 1200;
+        int DEVICE_RECOGNIZED = 1210;
+        int GET_LOCAL_PUBLIC_ADDRESS = 1220;
+        int DIRECTLY_CONNECTED_TO_PROFILE = 1230;
+        int DEVICE_ALIAS_CHANGED = 1240;
+        int WRITE_DEVICE_NAME = 1250;
+        int UPDATE_PROVIDER_NAME_START = 1260;
+        int UPDATE_PROVIDER_NAME_END = 1270;
+        int READ_FIRMWARE_VERSION = 1280;
+        int RETROACTIVE_PAIR_START = 1290;
+        int RETROACTIVE_PAIR_END = 1300;
+        int SUBSEQUENT_PAIR_START = 1310;
+        int SUBSEQUENT_PAIR_END = 1320;
+        int BISTO_PAIR_START = 1330;
+        int BISTO_PAIR_END = 1340;
+        int REMOTE_PAIR_START = 1350;
+        int REMOTE_PAIR_END = 1360;
+        int BEFORE_CREATE_BOND = 1370;
+        int BEFORE_CREATE_BOND_BONDING = 1380;
+        int BEFORE_CREATE_BOND_BONDED = 1390;
+        int BEFORE_CONNECT_PROFILE = 1400;
+        int HANDLE_PAIRING_REQUEST = 1410;
+        int SECRET_HANDSHAKE_GATT_COMMUNICATION = 1420;
+        int GATT_CONNECTION_AND_SECRET_HANDSHAKE = 1430;
+        int CHECK_SIGNAL_AFTER_HANDSHAKE = 1440;
+        int RECOVER_BY_RETRY_GATT = 1450;
+        int RECOVER_BY_RETRY_HANDSHAKE = 1460;
+        int RECOVER_BY_RETRY_HANDSHAKE_RECONNECT = 1470;
+        int GATT_HANDSHAKE_MANUAL_RETRY_ATTEMPTS = 1480;
+        int PAIR_WITH_CACHED_MODEL_ID = 1490;
+        int DIRECTLY_CONNECT_PROFILE_WITH_CACHED_ADDRESS = 1500;
+        int PAIR_WITH_NEW_MODEL = 1510;
+    }
+
+    private NearbyEventIntDefs() {}
+}
diff --git a/nearby/service/java/com/android/server/nearby/metrics/NearbyMetrics.java b/nearby/service/java/com/android/server/nearby/metrics/NearbyMetrics.java
new file mode 100644
index 0000000..75815f1
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/metrics/NearbyMetrics.java
@@ -0,0 +1,85 @@
+/*
+ * 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.metrics;
+
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.ScanRequest;
+import android.os.WorkSource;
+
+import com.android.server.nearby.proto.NearbyStatsLog;
+
+/**
+ * A class to collect and report Nearby metrics.
+ */
+public class NearbyMetrics {
+    /**
+     * Logs a scan started event.
+     */
+    public static void logScanStarted(int scanSessionId, ScanRequest scanRequest) {
+        NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                getUid(scanRequest),
+                scanSessionId,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_STARTED,
+                scanRequest.getScanType(),
+                0,
+                0,
+                "",
+                "");
+    }
+
+    /**
+     * Logs a scan stopped event.
+     */
+    public static void logScanStopped(int scanSessionId, ScanRequest scanRequest) {
+        NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                getUid(scanRequest),
+                scanSessionId,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_STOPPED,
+                scanRequest.getScanType(),
+                0,
+                0,
+                "",
+                "");
+    }
+
+    /**
+     * Logs a scan device discovered event.
+     */
+    public static void logScanDeviceDiscovered(int scanSessionId, ScanRequest scanRequest,
+            NearbyDeviceParcelable nearbyDevice) {
+        NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                getUid(scanRequest),
+                scanSessionId,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_DISCOVERED,
+                scanRequest.getScanType(),
+                nearbyDevice.getMedium(),
+                nearbyDevice.getRssi(),
+                nearbyDevice.getFastPairModelId(),
+                "");
+    }
+
+    private static int getUid(ScanRequest scanRequest) {
+        WorkSource workSource = scanRequest.getWorkSource();
+        return workSource.isEmpty() ? -1 : workSource.getUid(0);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/FastAdvertisement.java b/nearby/service/java/com/android/server/nearby/presence/FastAdvertisement.java
new file mode 100644
index 0000000..e4df673
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/FastAdvertisement.java
@@ -0,0 +1,203 @@
+/*
+ * 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.PresenceBroadcastRequest;
+import android.nearby.PresenceCredential;
+
+import com.android.internal.util.Preconditions;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+
+/**
+ * A Nearby Presence advertisement to be advertised on BT4.2 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 fast advertisement is defined in the format below:
+// Header (1 byte) | salt (2 bytes) | identity (14 bytes) | tx_power (1 byte) | actions (1~ bytes)
+// The header contains:
+// version (3 bits) | provision_mode_flag (1 bit) | identity_type (3 bits) |
+// extended_advertisement_mode (1 bit)
+public class FastAdvertisement {
+
+    private static final int FAST_ADVERTISEMENT_MAX_LENGTH = 24;
+
+    static final byte INVALID_TX_POWER = (byte) 0xFF;
+
+    static final int HEADER_LENGTH = 1;
+
+    static final int SALT_LENGTH = 2;
+
+    static final int IDENTITY_LENGTH = 14;
+
+    static final int TX_POWER_LENGTH = 1;
+
+    private static final int MAX_ACTION_COUNT = 6;
+
+    /**
+     * Creates a {@link FastAdvertisement} from a Presence Broadcast Request.
+     */
+    public static FastAdvertisement createFromRequest(PresenceBroadcastRequest request) {
+        byte[] salt = request.getSalt();
+        byte[] identity = request.getCredential().getMetadataEncryptionKey();
+        List<Integer> actions = request.getActions();
+        Preconditions.checkArgument(
+                salt.length == SALT_LENGTH,
+                "FastAdvertisement's salt does not match correct length");
+        Preconditions.checkArgument(
+                identity.length == IDENTITY_LENGTH,
+                "FastAdvertisement's identity does not match correct length");
+        Preconditions.checkArgument(
+                !actions.isEmpty(), "FastAdvertisement must contain at least one action");
+        Preconditions.checkArgument(
+                actions.size() <= MAX_ACTION_COUNT,
+                "FastAdvertisement advertised actions cannot exceed max count " + MAX_ACTION_COUNT);
+
+        return new FastAdvertisement(
+                request.getCredential().getIdentityType(),
+                identity,
+                salt,
+                actions,
+                (byte) request.getTxPower());
+    }
+
+    /** Serialize an {@link FastAdvertisement} object into bytes. */
+    public byte[] toBytes() {
+        ByteBuffer buffer = ByteBuffer.allocate(getLength());
+
+        buffer.put(FastAdvertisementUtils.constructHeader(getVersion(), mIdentityType));
+        buffer.put(mSalt);
+        buffer.put(getIdentity());
+
+        buffer.put(mTxPower == null ? INVALID_TX_POWER : mTxPower);
+        for (int action : mActions) {
+            buffer.put((byte) action);
+        }
+        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;
+
+    FastAdvertisement(
+            @PresenceCredential.IdentityType int identityType,
+            byte[] identity,
+            byte[] salt,
+            List<Integer> actions,
+            @Nullable Byte txPower) {
+        this.mIdentityType = identityType;
+        this.mIdentity = identity;
+        this.mSalt = salt;
+        this.mActions = actions;
+        this.mTxPower = txPower;
+        int ltvFieldCount = 3;
+        int length =
+                HEADER_LENGTH // header
+                        + identity.length
+                        + salt.length
+                        + actions.size();
+        length += TX_POWER_LENGTH;
+        if (txPower != null) { // TX power
+            ltvFieldCount += 1;
+        }
+        this.mLength = length;
+        this.mLtvFieldCount = ltvFieldCount;
+        Preconditions.checkArgument(
+                length <= FAST_ADVERTISEMENT_MAX_LENGTH,
+                "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;
+    }
+
+    @Override
+    public String toString() {
+        return String.format(
+                "FastAdvertisement:<VERSION: %s, length: %s, ltvFieldCount: %s, identityType: %s,"
+                        + " identity: %s, salt: %s, actions: %s, txPower: %s",
+                getVersion(),
+                getLength(),
+                getLtvFieldCount(),
+                getIdentityType(),
+                Arrays.toString(getIdentity()),
+                Arrays.toString(getSalt()),
+                getActions(),
+                getTxPower());
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/FastAdvertisementUtils.java b/nearby/service/java/com/android/server/nearby/presence/FastAdvertisementUtils.java
new file mode 100644
index 0000000..ab0a246
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/FastAdvertisementUtils.java
@@ -0,0 +1,40 @@
+/*
+ * 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.nearby.BroadcastRequest;
+
+/**
+ * Provides serialization and deserialization util methods for {@link FastAdvertisement}.
+ */
+public final class FastAdvertisementUtils {
+
+    private static final int VERSION_MASK = 0b11100000;
+
+    private static final int IDENTITY_TYPE_MASK = 0b00001110;
+
+    /**
+     * Constructs the header of a {@link FastAdvertisement}.
+     */
+    public static byte constructHeader(@BroadcastRequest.BroadcastVersion int version,
+            int identityType) {
+        return (byte) (((version << 5) & VERSION_MASK) | ((identityType << 1)
+                & IDENTITY_TYPE_MASK));
+    }
+
+    private FastAdvertisementUtils() {}
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/PresenceConstants.java b/nearby/service/java/com/android/server/nearby/presence/PresenceConstants.java
new file mode 100644
index 0000000..c355df2
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/PresenceConstants.java
@@ -0,0 +1,30 @@
+/*
+ * 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.common.bluetooth.fastpair.BluetoothUuids.to128BitUuid;
+
+import java.util.UUID;
+
+/**
+ * Constants for Nearby Presence operations.
+ */
+public class PresenceConstants {
+
+    /** Presence advertisement service data uuid. */
+    public static final UUID PRESENCE_UUID = to128BitUuid((short) 0xFCF1);
+}
diff --git a/nearby/service/java/com/android/server/nearby/presence/PresenceDiscoveryResult.java b/nearby/service/java/com/android/server/nearby/presence/PresenceDiscoveryResult.java
new file mode 100644
index 0000000..80ad88d
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/PresenceDiscoveryResult.java
@@ -0,0 +1,144 @@
+/*
+ * 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.nearby.NearbyDevice;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceDevice;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Represents a Presence discovery result. */
+public class PresenceDiscoveryResult {
+
+    /** Creates a {@link PresenceDiscoveryResult} from the scan data. */
+    public static PresenceDiscoveryResult fromDevice(NearbyDeviceParcelable device) {
+        return new PresenceDiscoveryResult.Builder()
+                .setTxPower(device.getTxPower())
+                .setRssi(device.getRssi())
+                .addPresenceAction(device.getAction())
+                .setPublicCredential(device.getPublicCredential())
+                .build();
+    }
+
+    private final int mTxPower;
+    private final int mRssi;
+    private final byte[] mSalt;
+    private final List<Integer> mPresenceActions;
+    private final PublicCredential mPublicCredential;
+
+    private PresenceDiscoveryResult(
+            int txPower,
+            int rssi,
+            byte[] salt,
+            List<Integer> presenceActions,
+            PublicCredential publicCredential) {
+        mTxPower = txPower;
+        mRssi = rssi;
+        mSalt = salt;
+        mPresenceActions = presenceActions;
+        mPublicCredential = publicCredential;
+    }
+
+    /** Returns whether the discovery result matches the scan filter. */
+    public boolean matches(PresenceScanFilter scanFilter) {
+        return pathLossMatches(scanFilter.getMaxPathLoss())
+                && actionMatches(scanFilter.getPresenceActions())
+                && credentialMatches(scanFilter.getCredentials());
+    }
+
+    private boolean pathLossMatches(int maxPathLoss) {
+        return (mTxPower - mRssi) <= maxPathLoss;
+    }
+
+    private boolean actionMatches(List<Integer> filterActions) {
+        if (filterActions.isEmpty()) {
+            return true;
+        }
+        return filterActions.stream().anyMatch(mPresenceActions::contains);
+    }
+
+    private boolean credentialMatches(List<PublicCredential> credentials) {
+        return credentials.contains(mPublicCredential);
+    }
+
+    /** 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();
+    }
+
+    /** Builder for {@link PresenceDiscoveryResult}. */
+    public static class Builder {
+        private int mTxPower;
+        private int mRssi;
+        private byte[] mSalt;
+
+        private PublicCredential mPublicCredential;
+        private final List<Integer> mPresenceActions;
+
+        public Builder() {
+            mPresenceActions = new ArrayList<>();
+        }
+
+        /** Sets the calibrated tx power for the discovery result. */
+        public Builder setTxPower(int txPower) {
+            mTxPower = txPower;
+            return this;
+        }
+
+        /** Sets the rssi for the discovery result. */
+        public Builder setRssi(int rssi) {
+            mRssi = rssi;
+            return this;
+        }
+
+        /** Sets the salt for the discovery result. */
+        public Builder setSalt(byte[] salt) {
+            mSalt = salt;
+            return this;
+        }
+
+        /** Sets the public credential for the discovery result. */
+        public Builder setPublicCredential(PublicCredential publicCredential) {
+            mPublicCredential = publicCredential;
+            return this;
+        }
+
+        /** Adds presence action of the discovery result. */
+        public Builder addPresenceAction(int presenceAction) {
+            mPresenceActions.add(presenceAction);
+            return this;
+        }
+
+        /** Builds a {@link PresenceDiscoveryResult}. */
+        public PresenceDiscoveryResult build() {
+            return new PresenceDiscoveryResult(
+                    mTxPower, mRssi, mSalt, mPresenceActions, mPublicCredential);
+        }
+    }
+}
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..382c47a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/presence/PresenceManager.java
@@ -0,0 +1,136 @@
+/*
+ * 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.NearbyDevice;
+import android.nearby.NearbyManager;
+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.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+
+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 LocatorContextWrapper mLocatorContextWrapper;
+    final Locator mLocator;
+    private final IntentFilter mIntentFilter;
+
+    private final ScanCallback mScanCallback =
+            new ScanCallback() {
+                @Override
+                public void onDiscovered(@NonNull NearbyDevice device) {
+                    Log.i(TAG, "[PresenceManager] discovered Device.");
+                }
+
+                @Override
+                public void onUpdated(@NonNull NearbyDevice device) {}
+
+                @Override
+                public void onLost(@NonNull NearbyDevice device) {}
+            };
+
+    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, "Start CHRE scan.");
+                        byte[] secreteId = {1, 0, 0, 0};
+                        byte[] authenticityKey = {2, 0, 0, 0};
+                        byte[] publicKey = {3, 0, 0, 0};
+                        byte[] encryptedMetaData = {4, 0, 0, 0};
+                        byte[] encryptedMetaDataTag = {5, 0, 0, 0};
+                        PublicCredential publicCredential =
+                                new PublicCredential.Builder(
+                                                secreteId,
+                                                authenticityKey,
+                                                publicKey,
+                                                encryptedMetaData,
+                                                encryptedMetaDataTag)
+                                        .build();
+                        PresenceScanFilter presenceScanFilter =
+                                new PresenceScanFilter.Builder()
+                                        .setMaxPathLoss(3)
+                                        .addCredential(publicCredential)
+                                        .addPresenceAction(1)
+                                        .build();
+                        ScanRequest scanRequest =
+                                new ScanRequest.Builder()
+                                        .setScanType(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE)
+                                        .addScanFilter(presenceScanFilter)
+                                        .build();
+                        Log.i(
+                                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, "Stop CHRE scan.");
+                        manager.stopScan(mScanCallback);
+                    }
+                }
+            };
+
+    public PresenceManager(LocatorContextWrapper contextWrapper) {
+        mLocatorContextWrapper = contextWrapper;
+        mLocator = mLocatorContextWrapper.getLocator();
+        mIntentFilter = new IntentFilter();
+    }
+
+    /** Null when the Nearby Service is not available. */
+    @Nullable
+    private NearbyManager getNearbyManager() {
+        return (NearbyManager)
+                mLocatorContextWrapper
+                        .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);
+        mLocatorContextWrapper
+                .getContext()
+                .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
new file mode 100644
index 0000000..f136695
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/AbstractDiscoveryProvider.java
@@ -0,0 +1,144 @@
+/*
+ * 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 com.android.server.nearby.NearbyService.TAG;
+
+import android.content.Context;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/**
+ * Base class for all discovery providers.
+ *
+ * @hide
+ */
+public abstract class AbstractDiscoveryProvider {
+
+    protected final Context mContext;
+    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;
+        mExecutor = executor;
+        mController = new Controller();
+    }
+
+    /**
+     * Callback invoked when the provider is started, and signals that other callback invocations
+     * can now be expected. Always implies that the provider request is set to the empty request.
+     * Always invoked on the provider executor.
+     */
+    protected void onStart() {}
+
+    /**
+     * Callback invoked when the provider is stopped, and signals that no further callback
+     * invocations will occur (until a further call to {@link #onStart()}. Always invoked on the
+     * provider executor.
+     */
+    protected void onStop() {}
+
+    /**
+     * Callback invoked to inform the provider of a new provider request which replaces any prior
+     * provider request. Always invoked on the provider executor.
+     */
+    protected void invalidateScanMode() {}
+
+    /**
+     * 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() {
+        return mController;
+    }
+
+    private class Controller implements DiscoveryProviderController {
+
+        private boolean mStarted = false;
+        private @ScanRequest.ScanMode int mScanMode;
+
+        @Override
+        public void setListener(@Nullable Listener listener) {
+            mListener = listener;
+        }
+
+        @Override
+        public boolean isStarted() {
+            return mStarted;
+        }
+
+        @Override
+        public void start() {
+            if (mStarted) {
+                Log.d(TAG, "Provider already started.");
+                return;
+            }
+            mStarted = true;
+            mExecutor.execute(AbstractDiscoveryProvider.this::onStart);
+        }
+
+        @Override
+        public void stop() {
+            if (!mStarted) {
+                Log.d(TAG, "Provider already stopped.");
+                return;
+            }
+            mStarted = false;
+            mExecutor.execute(AbstractDiscoveryProvider.this::onStop);
+        }
+
+        @Override
+        public void setProviderScanMode(@ScanRequest.ScanMode int scanMode) {
+            if (mScanMode == scanMode) {
+                Log.d(TAG, "Provider already in desired scan mode.");
+                return;
+            }
+            mScanMode = scanMode;
+            mExecutor.execute(AbstractDiscoveryProvider.this::invalidateScanMode);
+        }
+
+        @ScanRequest.ScanMode
+        @Override
+        public int getProviderScanMode() {
+            return mScanMode;
+        }
+
+        @Override
+        public void setProviderScanFilters(List<ScanFilter> filters) {
+            mScanFilters = 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
new file mode 100644
index 0000000..3602787
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
@@ -0,0 +1,121 @@
+/*
+ * 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.provider;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.nearby.BroadcastCallback;
+import android.os.ParcelUuid;
+
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.presence.PresenceConstants;
+
+import java.util.concurrent.Executor;
+
+/**
+ * A provider for Bluetooth Low Energy advertisement.
+ */
+public class BleBroadcastProvider extends AdvertiseCallback {
+
+    /**
+     * Listener for Broadcast status changes.
+     */
+    interface BroadcastListener {
+        void onStatusChanged(int status);
+    }
+
+    private final Injector mInjector;
+    private final Executor mExecutor;
+
+    private BroadcastListener mBroadcastListener;
+    private boolean mIsAdvertising;
+
+    BleBroadcastProvider(Injector injector, Executor executor) {
+        mInjector = injector;
+        mExecutor = executor;
+    }
+
+    void start(byte[] advertisementPackets, BroadcastListener listener) {
+        if (mIsAdvertising) {
+            stop();
+        }
+        boolean advertiseStarted = false;
+        BluetoothAdapter adapter = mInjector.getBluetoothAdapter();
+        if (adapter != null) {
+            BluetoothLeAdvertiser bluetoothLeAdvertiser =
+                    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();
+                AdvertiseData advertiseData =
+                        new AdvertiseData.Builder()
+                                .addServiceData(new ParcelUuid(PresenceConstants.PRESENCE_UUID),
+                                        advertisementPackets).build();
+
+                try {
+                    mBroadcastListener = listener;
+                    bluetoothLeAdvertiser.startAdvertising(settings, advertiseData, this);
+                } catch (NullPointerException | IllegalStateException | SecurityException e) {
+                    advertiseStarted = false;
+                }
+            }
+        }
+        if (!advertiseStarted) {
+            listener.onStatusChanged(BroadcastCallback.STATUS_FAILURE);
+        }
+    }
+
+    void stop() {
+        if (mIsAdvertising) {
+            BluetoothAdapter adapter = mInjector.getBluetoothAdapter();
+            if (adapter != null) {
+                BluetoothLeAdvertiser bluetoothLeAdvertiser =
+                        mInjector.getBluetoothAdapter().getBluetoothLeAdvertiser();
+                if (bluetoothLeAdvertiser != null) {
+                    bluetoothLeAdvertiser.stopAdvertising(this);
+                }
+            }
+            mBroadcastListener = null;
+            mIsAdvertising = false;
+        }
+    }
+
+    @Override
+    public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+        mExecutor.execute(() -> {
+            if (mBroadcastListener != null) {
+                mBroadcastListener.onStatusChanged(BroadcastCallback.STATUS_OK);
+            }
+            mIsAdvertising = true;
+        });
+    }
+
+    @Override
+    public void onStartFailure(int errorCode) {
+        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
new file mode 100644
index 0000000..db58476
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/BleDiscoveryProvider.java
@@ -0,0 +1,202 @@
+/*
+ * 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 com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.nearby.NearbyDevice;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.ScanRequest;
+import android.os.ParcelUuid;
+import android.util.Log;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants;
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.presence.PresenceConstants;
+import com.android.server.nearby.util.ForegroundThread;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executor;
+
+/**
+ * Discovery provider that uses Bluetooth Low Energy to do scanning.
+ */
+public class BleDiscoveryProvider extends AbstractDiscoveryProvider {
+
+    @VisibleForTesting
+    static final ParcelUuid FAST_PAIR_UUID = new ParcelUuid(Constants.FastPairService.ID);
+    private static final ParcelUuid PRESENCE_UUID = new ParcelUuid(PresenceConstants.PRESENCE_UUID);
+
+    // 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 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)
+                            .setRssi(scanResult.getRssi())
+                            .setTxPower(scanResult.getTxPower())
+                            .setBluetoothAddress(scanResult.getDevice().getAddress());
+
+                    ScanRecord record = scanResult.getScanRecord();
+                    if (record != null) {
+                        String deviceName = record.getDeviceName();
+                        if (deviceName != null) {
+                            builder.setName(record.getDeviceName());
+                        }
+                        Map<ParcelUuid, byte[]> serviceDataMap = record.getServiceData();
+                        byte[] fastPairData = serviceDataMap.get(FAST_PAIR_UUID);
+                        if (fastPairData != null) {
+                            builder.setData(serviceDataMap.get(FAST_PAIR_UUID));
+                        } else {
+                            byte [] presenceData = serviceDataMap.get(PRESENCE_UUID);
+                            if (presenceData != null) {
+                                builder.setData(serviceDataMap.get(PRESENCE_UUID));
+                            }
+                        }
+                    }
+                    mExecutor.execute(() -> mListener.onNearbyDeviceDiscovered(builder.build()));
+                }
+
+                @Override
+                public void onScanFailed(int errorCode) {
+                    Log.w(TAG, "BLE Scan failed with error code " + errorCode);
+                }
+            };
+
+    public BleDiscoveryProvider(Context context, Injector injector) {
+        super(context, NEARBY_EXECUTOR);
+        mInjector = injector;
+    }
+
+    private static List<ScanFilter> getScanFilters() {
+        List<ScanFilter> scanFilterList = new ArrayList<>();
+        scanFilterList.add(
+                new ScanFilter.Builder()
+                        .setServiceData(FAST_PAIR_UUID, new byte[]{0}, new byte[]{0})
+                        .build());
+        return scanFilterList;
+    }
+
+    private boolean isBleAvailable() {
+        BluetoothAdapter adapter = mInjector.getBluetoothAdapter();
+        if (adapter == null) {
+            return false;
+        }
+
+        return adapter.getBluetoothLeScanner() != null;
+    }
+
+    @Nullable
+    private BluetoothLeScanner getBleScanner() {
+        BluetoothAdapter adapter = mInjector.getBluetoothAdapter();
+        if (adapter == null) {
+            return null;
+        }
+        return adapter.getBluetoothLeScanner();
+    }
+
+    @Override
+    protected void onStart() {
+        if (isBleAvailable()) {
+            Log.d(TAG, "BleDiscoveryProvider started.");
+            startScan(getScanFilters(), getScanSettings(), mScanCallback);
+            return;
+        }
+        Log.w(TAG, "Cannot start BleDiscoveryProvider because Ble is not available.");
+        mController.stop();
+    }
+
+    @Override
+    protected void onStop() {
+        BluetoothLeScanner bluetoothLeScanner = getBleScanner();
+        if (bluetoothLeScanner == null) {
+            Log.w(TAG, "BleDiscoveryProvider failed to stop BLE scanning "
+                    + "because BluetoothLeScanner is null.");
+            return;
+        }
+        bluetoothLeScanner.stopScan(mScanCallback);
+    }
+
+    @Override
+    protected void invalidateScanMode() {
+        onStop();
+        onStart();
+    }
+
+    private void startScan(
+            List<ScanFilter> scanFilters, ScanSettings scanSettings,
+            android.bluetooth.le.ScanCallback scanCallback) {
+        try {
+            BluetoothLeScanner bluetoothLeScanner = getBleScanner();
+            if (bluetoothLeScanner == null) {
+                Log.w(TAG, "BleDiscoveryProvider failed to start BLE scanning "
+                        + "because BluetoothLeScanner is null.");
+                return;
+            }
+            bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback);
+        } catch (NullPointerException | IllegalStateException | SecurityException e) {
+            // NullPointerException:
+            //   - Commonly, on Blackberry devices. b/73299795
+            //   - Rarely, on other devices. b/75285249
+            // IllegalStateException:
+            // Caused if we call BluetoothLeScanner.startScan() after Bluetooth has turned off.
+            // SecurityException:
+            // refer to b/177380884
+            Log.w(TAG, "BleDiscoveryProvider failed to start BLE scanning.", e);
+        }
+    }
+
+    private ScanSettings getScanSettings() {
+        int bleScanMode = ScanSettings.SCAN_MODE_LOW_POWER;
+        switch (mController.getProviderScanMode()) {
+            case ScanRequest.SCAN_MODE_LOW_LATENCY:
+                bleScanMode = ScanSettings.SCAN_MODE_LOW_LATENCY;
+                break;
+            case ScanRequest.SCAN_MODE_BALANCED:
+                bleScanMode = ScanSettings.SCAN_MODE_BALANCED;
+                break;
+            case ScanRequest.SCAN_MODE_LOW_POWER:
+                bleScanMode = ScanSettings.SCAN_MODE_LOW_POWER;
+                break;
+            case ScanRequest.SCAN_MODE_NO_POWER:
+                bleScanMode = ScanSettings.SCAN_MODE_OPPORTUNISTIC;
+                break;
+        }
+        return new ScanSettings.Builder().setScanMode(bleScanMode).build();
+    }
+
+    @VisibleForTesting
+    ScanCallback getScanCallback() {
+        return mScanCallback;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/BroadcastProviderManager.java b/nearby/service/java/com/android/server/nearby/provider/BroadcastProviderManager.java
new file mode 100644
index 0000000..72fe29a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/BroadcastProviderManager.java
@@ -0,0 +1,126 @@
+/*
+ * 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.provider;
+
+import android.content.Context;
+import android.nearby.BroadcastCallback;
+import android.nearby.BroadcastRequest;
+import android.nearby.IBroadcastListener;
+import android.nearby.PresenceBroadcastRequest;
+import android.os.RemoteException;
+import android.util.Log;
+
+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.FastAdvertisement;
+import com.android.server.nearby.util.ForegroundThread;
+
+import java.util.concurrent.Executor;
+
+/**
+ * A manager for nearby broadcasts.
+ */
+public class BroadcastProviderManager implements BleBroadcastProvider.BroadcastListener {
+
+    private static final String TAG = "BroadcastProvider";
+
+    private final Object mLock;
+    private final BleBroadcastProvider mBleBroadcastProvider;
+    private final Executor mExecutor;
+    private final NearbyConfiguration mNearbyConfiguration;
+
+    private IBroadcastListener mBroadcastListener;
+
+    public BroadcastProviderManager(Context context, Injector injector) {
+        this(ForegroundThread.getExecutor(),
+                new BleBroadcastProvider(injector, ForegroundThread.getExecutor()));
+    }
+
+    @VisibleForTesting
+    BroadcastProviderManager(Executor executor, BleBroadcastProvider bleBroadcastProvider) {
+        mExecutor = executor;
+        mBleBroadcastProvider = bleBroadcastProvider;
+        mLock = new Object();
+        mNearbyConfiguration = new NearbyConfiguration();
+        mBroadcastListener = null;
+    }
+
+    /**
+     * Starts a nearby broadcast, the callback is sent through the given listener.
+     */
+    public void startBroadcast(BroadcastRequest broadcastRequest, IBroadcastListener listener) {
+        synchronized (mLock) {
+            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);
+                return;
+            }
+            PresenceBroadcastRequest presenceBroadcastRequest =
+                    (PresenceBroadcastRequest) broadcastRequest;
+            if (presenceBroadcastRequest.getVersion() != BroadcastRequest.PRESENCE_VERSION_V0) {
+                reportBroadcastStatus(listener, BroadcastCallback.STATUS_FAILURE);
+                return;
+            }
+            FastAdvertisement fastAdvertisement = FastAdvertisement.createFromRequest(
+                    presenceBroadcastRequest);
+            byte[] advertisementPackets = fastAdvertisement.toBytes();
+            mBroadcastListener = listener;
+            mExecutor.execute(() -> {
+                mBleBroadcastProvider.start(advertisementPackets, this);
+            });
+        }
+    }
+
+    /**
+     * Stops the nearby broadcast.
+     */
+    public void stopBroadcast(IBroadcastListener listener) {
+        synchronized (mLock) {
+            if (!mNearbyConfiguration.isPresenceBroadcastLegacyEnabled()) {
+                reportBroadcastStatus(listener, BroadcastCallback.STATUS_FAILURE);
+                return;
+            }
+            mBroadcastListener = null;
+            mExecutor.execute(() -> mBleBroadcastProvider.stop());
+        }
+    }
+
+    @Override
+    public void onStatusChanged(int status) {
+        IBroadcastListener listener = null;
+        synchronized (mLock) {
+            listener = mBroadcastListener;
+        }
+        // Don't invoke callback while holding the local lock, as this could cause deadlock.
+        if (listener != null) {
+            reportBroadcastStatus(listener, status);
+        }
+    }
+
+    private void reportBroadcastStatus(IBroadcastListener listener, int status) {
+        try {
+            listener.onStatusChanged(status);
+        } catch (RemoteException exception) {
+            Log.e(TAG, "remote exception when reporting status");
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java b/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java
new file mode 100644
index 0000000..607c579
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/ChreCommunication.java
@@ -0,0 +1,269 @@
+/*
+ * 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.provider;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.hardware.location.ContextHubClient;
+import android.hardware.location.ContextHubClientCallback;
+import android.hardware.location.ContextHubInfo;
+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.server.nearby.injector.Injector;
+
+import com.google.common.base.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.Executor;
+
+/**
+ * Responsible for setting up communication with the appropriate contexthub on the device and
+ * handling nanoapp messages to / from it.
+ */
+public class ChreCommunication extends ContextHubClientCallback {
+
+    /** Callback that receives messages forwarded from the context hub. */
+    public interface ContextHubCommsCallback {
+        /** Indicates whether {@link ChreCommunication} was started successfully. */
+        void started(boolean success);
+
+        /** Indicates the ContextHub has been restarted. */
+        void onHubReset();
+
+        /**
+         * Indicates the given {@code nanoAppId} has been restarted. Either via code download or by
+         * being enabled by CHRE.
+         */
+        void onNanoAppRestart(long nanoAppId);
+
+        /** Indicates a new {@link NanoAppMessage} has been received. */
+        void onMessageFromNanoApp(NanoAppMessage message);
+    }
+
+    private final Injector mInjector;
+    private final Executor mExecutor;
+
+    private boolean mStarted = false;
+    @Nullable private ContextHubCommsCallback mCallback;
+    @Nullable private ContextHubClient mContextHubClient;
+
+    public ChreCommunication(Injector injector, Executor executor) {
+        mInjector = injector;
+        mExecutor = executor;
+    }
+
+    public boolean available() {
+        return mInjector.getContextHubManagerAdapter() != null;
+    }
+
+    /**
+     * Starts communication with the contexthub. This will invoke {@link
+     * ContextHubCommsCallback#start(boolean)} on completion.
+     *
+     * @param nanoAppIds - List of IDs that must have at least one match inside the chosen
+     *     contexthub.
+     */
+    public synchronized void start(ContextHubCommsCallback callback, Set<Long> nanoAppIds) {
+        ContextHubManagerAdapter manager = mInjector.getContextHubManagerAdapter();
+        if (manager == null) {
+            Log.e(TAG, "ContexHub not available in this device");
+            return;
+        } else {
+            Log.i(TAG, "Start ChreCommunication");
+        }
+        Preconditions.checkNotNull(callback);
+        Preconditions.checkArgument(!nanoAppIds.isEmpty());
+        if (mStarted) {
+            Log.i(TAG, "ChreCommunication already started");
+            this.mCallback.started(true);
+            return;
+        }
+
+        // Use this to indicate whether stop was called before the transaction below
+        // completes.
+        mStarted = true;
+        this.mCallback = callback;
+
+        List<ContextHubInfo> contextHubs = manager.getContextHubs();
+
+        // Make a copy of the list so we can modify it during our async callbacks (in case the code
+        // is still iterating)
+        List<ContextHubInfo> validContextHubs = new ArrayList<>(contextHubs);
+
+        for (ContextHubInfo info : contextHubs) {
+            ContextHubTransaction<List<NanoAppState>> transaction = manager.queryNanoApps(info);
+            Log.i(TAG, "After query Nano Apps ");
+            transaction.setOnCompleteListener(
+                    new OnQueryCompleteListener(info, validContextHubs, nanoAppIds, manager),
+                    mExecutor);
+        }
+    }
+
+    /**
+     * Closes the connection to the {@link ContextHub} chosen during start.
+     *
+     * <p>NOTE: Do not invoke any other methods on this class after this returns.
+     */
+    public synchronized void stop() {
+        if (!mStarted) {
+            return;
+        }
+        mStarted = false;
+        if (mContextHubClient != null) {
+            mContextHubClient.close();
+            mContextHubClient = null;
+        }
+    }
+
+    /** Sends a {@link NanoAppMessage} to Context Hub Nearby nanoapp. */
+    public synchronized boolean sendMessageToNanoApp(NanoAppMessage message) {
+        if (mContextHubClient == null) {
+            Log.i(TAG, "Error sending message to nanoapp, contextHubClient is null");
+            return false;
+        }
+        int result = mContextHubClient.sendMessageToNanoApp(message);
+        if (result != ContextHubTransaction.RESULT_SUCCESS) {
+            Log.i(
+                    TAG,
+                    String.format(
+                            Locale.getDefault(),
+                            "Error sending message to nanoapp: %s",
+                            contextHubTransactionResultToString(result)));
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public synchronized void onMessageFromNanoApp(ContextHubClient client, NanoAppMessage message) {
+        mCallback.onMessageFromNanoApp(message);
+    }
+
+    @Override
+    public synchronized void onHubReset(ContextHubClient client) {
+        mCallback.onHubReset();
+    }
+
+    @Override
+    public synchronized void onNanoAppLoaded(ContextHubClient client, long nanoAppId) {
+        Log.i(TAG, String.format("Nanoapp ID loaded: %s", nanoAppId));
+        mCallback.onNanoAppRestart(nanoAppId);
+    }
+
+    private static String contextHubTransactionResultToString(int result) {
+        switch (result) {
+            case ContextHubTransaction.RESULT_SUCCESS:
+                return "RESULT_SUCCESS";
+            case ContextHubTransaction.RESULT_FAILED_UNKNOWN:
+                return "RESULT_FAILED_UNKNOWN";
+            case ContextHubTransaction.RESULT_FAILED_BAD_PARAMS:
+                return "RESULT_FAILED_BAD_PARAMS";
+            case ContextHubTransaction.RESULT_FAILED_UNINITIALIZED:
+                return "RESULT_FAILED_UNINITIALIZED";
+            case ContextHubTransaction.RESULT_FAILED_BUSY:
+                return "RESULT_FAILED_BUSY";
+            case ContextHubTransaction.RESULT_FAILED_AT_HUB:
+                return "RESULT_FAILED_AT_HUB";
+            case ContextHubTransaction.RESULT_FAILED_TIMEOUT:
+                return "RESULT_FAILED_TIMEOUT";
+            case ContextHubTransaction.RESULT_FAILED_SERVICE_INTERNAL_FAILURE:
+                return "RESULT_FAILED_SERVICE_INTERNAL_FAILURE";
+            case ContextHubTransaction.RESULT_FAILED_HAL_UNAVAILABLE:
+                return "RESULT_FAILED_HAL_UNAVAILABLE";
+            default:
+                return String.format(Locale.getDefault(), "UNKNOWN_RESULT value=%d", result);
+        }
+    }
+
+    /**
+     * Used when initializing the class to identify the appropriate {@link ContextHubInfo} to listen
+     * to.
+     */
+    class OnQueryCompleteListener
+            implements ContextHubTransaction.OnCompleteListener<List<NanoAppState>> {
+
+        private final ContextHubInfo mQueriedContextHub;
+        private final List<ContextHubInfo> mContextHubs;
+        private final Set<Long> mNanoAppIds;
+        private final ContextHubManagerAdapter mManager;
+
+        OnQueryCompleteListener(
+                ContextHubInfo queriedContextHub,
+                List<ContextHubInfo> contextHubs,
+                Set<Long> nanoAppIds,
+                ContextHubManagerAdapter manager) {
+            this.mQueriedContextHub = queriedContextHub;
+            this.mContextHubs = contextHubs;
+            this.mNanoAppIds = nanoAppIds;
+            this.mManager = manager;
+        }
+
+        @Override
+        public void onComplete(
+                ContextHubTransaction<List<NanoAppState>> transaction,
+                ContextHubTransaction.Response<List<NanoAppState>> response) {
+            Log.i(TAG, "query nano app onComplete");
+            // Ensure the class hasn't found a client already or stop hasn't been called before
+            // the transaction completed to avoid messing with state.
+            if (mContextHubClient != null || !mStarted) {
+                return;
+            }
+
+            if (response.getResult() == ContextHubTransaction.RESULT_SUCCESS) {
+                for (NanoAppState state : response.getContents()) {
+                    if (mNanoAppIds.contains(state.getNanoAppId())) {
+                        Log.i(
+                                TAG,
+                                String.format(
+                                        "Found valid contexthub: %s", mQueriedContextHub.getId()));
+                        mContextHubClient =
+                                mManager.createClient(
+                                        mQueriedContextHub, ChreCommunication.this, mExecutor);
+                        mCallback.started(true);
+                        return;
+                    }
+                }
+                Log.e(
+                        TAG,
+                        String.format(
+                                "Didn't find the nanoapp on contexthub: %s",
+                                mQueriedContextHub.getId()));
+            } else {
+                Log.e(
+                        TAG,
+                        String.format(
+                                "Failed to communicate with contexthub: %s",
+                                mQueriedContextHub.getId()));
+            }
+
+            mContextHubs.remove(mQueriedContextHub);
+            // 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);
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/ChreDiscoveryProvider.java b/nearby/service/java/com/android/server/nearby/provider/ChreDiscoveryProvider.java
new file mode 100644
index 0000000..a70ef13
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/ChreDiscoveryProvider.java
@@ -0,0 +1,194 @@
+/*
+ * 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.provider;
+
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.content.Context;
+import android.hardware.location.NanoAppMessage;
+import android.nearby.NearbyDevice;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanFilter;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import service.proto.Blefilter;
+
+import java.util.Collections;
+import java.util.concurrent.Executor;
+
+/** Discovery provider that uses CHRE Nearby Nanoapp to do scanning. */
+public class ChreDiscoveryProvider extends AbstractDiscoveryProvider {
+    // Nanoapp ID reserved for Nearby Presence.
+    /** @hide */
+    @VisibleForTesting public static final long NANOAPP_ID = 0x476f6f676c001031L;
+    /** @hide */
+    @VisibleForTesting public static final int NANOAPP_MESSAGE_TYPE_FILTER = 3;
+    /** @hide */
+    @VisibleForTesting public static final int NANOAPP_MESSAGE_TYPE_FILTER_RESULT = 4;
+
+    private static final int PRESENCE_UUID = 0xFCF1;
+
+    private ChreCommunication mChreCommunication;
+    private ChreCallback mChreCallback;
+    private boolean mChreStarted = false;
+    private Blefilter.BleFilters mFilters = null;
+    private int mFilterId;
+
+    public ChreDiscoveryProvider(
+            Context context, ChreCommunication chreCommunication, Executor executor) {
+        super(context, executor);
+        mChreCommunication = chreCommunication;
+        mChreCallback = new ChreCallback();
+        mFilterId = 0;
+    }
+
+    @Override
+    protected void onStart() {
+        Log.d(TAG, "Start CHRE scan");
+        mChreCommunication.start(mChreCallback, Collections.singleton(NANOAPP_ID));
+        updateFilters();
+    }
+
+    @Override
+    protected void onStop() {
+        mChreStarted = false;
+        mChreCommunication.stop();
+    }
+
+    @Override
+    protected void invalidateScanMode() {
+        onStop();
+        onStart();
+    }
+
+    public boolean available() {
+        return mChreCommunication.available();
+    }
+
+    private synchronized void updateFilters() {
+        if (mScanFilters == null) {
+            Log.e(TAG, "ScanFilters not set.");
+            return;
+        }
+        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++;
+        }
+        mFilters = filtersBuilder.build();
+        if (mChreStarted) {
+            sendFilters(mFilters);
+            mFilters = null;
+        }
+    }
+
+    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.");
+        }
+    }
+
+    private class ChreCallback implements ChreCommunication.ContextHubCommsCallback {
+
+        @Override
+        public void started(boolean success) {
+            if (success) {
+                synchronized (ChreDiscoveryProvider.this) {
+                    Log.i(TAG, "CHRE communication started");
+                    mChreStarted = true;
+                    if (mFilters != null) {
+                        sendFilters(mFilters);
+                        mFilters = null;
+                    }
+                }
+            }
+        }
+
+        @Override
+        public void onHubReset() {
+            // TODO(b/221082271): hooked with upper level codes.
+            Log.i(TAG, "CHRE reset.");
+        }
+
+        @Override
+        public void onNanoAppRestart(long nanoAppId) {
+            // TODO(b/221082271): hooked with upper level codes.
+            Log.i(TAG, String.format("CHRE NanoApp %d restart.", nanoAppId));
+        }
+
+        @Override
+        public void onMessageFromNanoApp(NanoAppMessage message) {
+            if (message.getNanoAppId() != NANOAPP_ID) {
+                Log.e(TAG, "Received message from unknown nano app.");
+                return;
+            }
+            if (mListener == null) {
+                Log.e(TAG, "the listener is not set in ChreDiscoveryProvider.");
+                return;
+            }
+            if (message.getMessageType() == NANOAPP_MESSAGE_TYPE_FILTER_RESULT) {
+                try {
+                    Blefilter.BleFilterResults results =
+                            Blefilter.BleFilterResults.parseFrom(message.getMessageBody());
+                    for (Blefilter.BleFilterResult filterResult : results.getResultList()) {
+                        Blefilter.PublicCredential credential = filterResult.getPublicCredential();
+                        PublicCredential publicCredential =
+                                new PublicCredential.Builder(
+                                                credential.getSecretId().toByteArray(),
+                                                credential.getAuthenticityKey().toByteArray(),
+                                                credential.getPublicKey().toByteArray(),
+                                                credential.getEncryptedMetadata().toByteArray(),
+                                                credential.getEncryptedMetadataTag().toByteArray())
+                                        .build();
+                        NearbyDeviceParcelable device =
+                                new NearbyDeviceParcelable.Builder()
+                                        .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                                        .setMedium(NearbyDevice.Medium.BLE)
+                                        .setTxPower(filterResult.getTxPower())
+                                        .setRssi(filterResult.getRssi())
+                                        .setAction(filterResult.getIntent())
+                                        .setPublicCredential(publicCredential)
+                                        .build();
+                        mExecutor.execute(() -> mListener.onNearbyDeviceDiscovered(device));
+                    }
+                } catch (InvalidProtocolBufferException e) {
+                    Log.e(
+                            TAG,
+                            String.format("Failed to decode the filter result %s", e.toString()));
+                }
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderController.java b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderController.java
new file mode 100644
index 0000000..fa1a874
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderController.java
@@ -0,0 +1,59 @@
+/*
+ * 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 android.annotation.Nullable;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+
+import java.util.List;
+
+/** Interface for controlling discovery providers. */
+interface DiscoveryProviderController {
+
+    /**
+     * Sets the listener which can expect to receive all state updates from after this point. May be
+     * invoked at any time.
+     */
+    void setListener(@Nullable AbstractDiscoveryProvider.Listener listener);
+
+    /** Returns true if in the started state. */
+    boolean isStarted();
+
+    /**
+     * Starts the discovery provider. Must be invoked before any other method (except {@link
+     * #setListener(AbstractDiscoveryProvider.Listener)} (Listener)}).
+     */
+    void start();
+
+    /**
+     * Stops the discovery provider. No other methods may be invoked after this method (except
+     * {@link #setListener(AbstractDiscoveryProvider.Listener)} (Listener)}), until {@link #start()}
+     * is called again.
+     */
+    void stop();
+
+    /** Sets the desired scan mode. */
+    void setProviderScanMode(@ScanRequest.ScanMode int scanMode);
+
+    /** Gets the controller scan mode. */
+    @ScanRequest.ScanMode
+    int getProviderScanMode();
+
+    /** Sets the scan filters. */
+    void setProviderScanFilters(List<ScanFilter> filters);
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java
new file mode 100644
index 0000000..af1c0b5
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java
@@ -0,0 +1,299 @@
+/*
+ * 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.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 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;
+
+    @GuardedBy("mLock")
+    private Map<IBinder, ScanListenerRecord> mScanTypeScanListenerRecordMap;
+
+    @Override
+    public void onNearbyDeviceDiscovered(NearbyDeviceParcelable nearbyDevice) {
+        synchronized (mLock) {
+            for (IBinder listenerBinder : mScanTypeScanListenerRecordMap.keySet()) {
+                ScanListenerRecord record = mScanTypeScanListenerRecordMap.get(listenerBinder);
+                if (record == null) {
+                    Log.w(TAG, "DiscoveryProviderManager cannot find the scan record.");
+                    continue;
+                }
+                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<>();
+    }
+
+    /**
+     * Registers the listener in the manager and starts scan according to the requested scan mode.
+     */
+    public boolean registerScanListener(ScanRequest scanRequest, IScanListener listener) {
+        Log.i(TAG, "DiscoveryProviderManager registerScanListener");
+        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);
+            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);
+            NearbyMetrics.logScanStopped(removedRecord.hashCode(), removedRecord.getScanRequest());
+            if (mScanTypeScanListenerRecordMap.isEmpty()) {
+                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) {
+                @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()) {
+                startChreProvider();
+                return true;
+            } 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;
+
+        ScanListenerRecord(ScanRequest scanRequest, IScanListener iScanListener) {
+            mScanListener = iScanListener;
+            mScanRequest = scanRequest;
+        }
+
+        IScanListener getScanListener() {
+            return mScanListener;
+        }
+
+        ScanRequest getScanRequest() {
+            return mScanRequest;
+        }
+
+        @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/provider/FastPairDataProvider.java b/nearby/service/java/com/android/server/nearby/provider/FastPairDataProvider.java
new file mode 100644
index 0000000..0f99a2f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/FastPairDataProvider.java
@@ -0,0 +1,199 @@
+/*
+ * 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 android.accounts.Account;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.nearby.FastPairDataProviderService;
+import android.nearby.aidl.ByteArrayParcel;
+import android.nearby.aidl.FastPairAccountDevicesMetadataRequestParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataRequestParcel;
+import android.nearby.aidl.FastPairEligibleAccountsRequestParcel;
+import android.nearby.aidl.FastPairManageAccountDeviceRequestParcel;
+import android.nearby.aidl.FastPairManageAccountRequestParcel;
+import android.util.Log;
+
+import androidx.annotation.WorkerThread;
+
+import com.android.server.nearby.common.bloomfilter.BloomFilter;
+import com.android.server.nearby.fastpair.footprint.FastPairUploadInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import service.proto.Data;
+import service.proto.Rpcs;
+
+/**
+ * FastPairDataProvider is a singleton that implements APIs to get FastPair data.
+ */
+public class FastPairDataProvider {
+
+    private static final String TAG = "FastPairDataProvider";
+
+    private static FastPairDataProvider sInstance;
+
+    private ProxyFastPairDataProvider mProxyFastPairDataProvider;
+
+    /**
+     * Initializes FastPairDataProvider singleton.
+     */
+    public static synchronized FastPairDataProvider init(Context context) {
+        if (sInstance == null) {
+            sInstance = new FastPairDataProvider(context);
+        }
+        if (sInstance.mProxyFastPairDataProvider == null) {
+            Log.w(TAG, "no proxy fast pair data provider found");
+        } else {
+            sInstance.mProxyFastPairDataProvider.register();
+        }
+        return sInstance;
+    }
+
+    @Nullable
+    public static synchronized FastPairDataProvider getInstance() {
+        return sInstance;
+    }
+
+    private FastPairDataProvider(Context context) {
+        mProxyFastPairDataProvider = ProxyFastPairDataProvider.create(
+                context, FastPairDataProviderService.ACTION_FAST_PAIR_DATA_PROVIDER);
+        if (mProxyFastPairDataProvider == null) {
+            Log.d("FastPairService", "fail to initiate the fast pair proxy provider");
+        } else {
+            Log.d("FastPairService", "the fast pair proxy provider initiated");
+        }
+    }
+
+    /**
+     * Loads FastPairAntispoofKeyDeviceMetadata.
+     *
+     * @throws IllegalStateException If ProxyFastPairDataProvider is not available.
+     */
+    @WorkerThread
+    @Nullable
+    public Rpcs.GetObservedDeviceResponse loadFastPairAntispoofKeyDeviceMetadata(byte[] modelId) {
+        if (mProxyFastPairDataProvider != null) {
+            FastPairAntispoofKeyDeviceMetadataRequestParcel requestParcel =
+                    new FastPairAntispoofKeyDeviceMetadataRequestParcel();
+            requestParcel.modelId = modelId;
+            return Utils.convertToGetObservedDeviceResponse(
+                    mProxyFastPairDataProvider
+                            .loadFastPairAntispoofKeyDeviceMetadata(requestParcel));
+        }
+        throw new IllegalStateException("No ProxyFastPairDataProvider yet constructed");
+    }
+
+    /**
+     * Enrolls an account to Fast Pair.
+     *
+     * @throws IllegalStateException If ProxyFastPairDataProvider is not available.
+     */
+    public void optIn(Account account) {
+        if (mProxyFastPairDataProvider != null) {
+            FastPairManageAccountRequestParcel requestParcel =
+                    new FastPairManageAccountRequestParcel();
+            requestParcel.account = account;
+            requestParcel.requestType = FastPairDataProviderService.MANAGE_REQUEST_ADD;
+            mProxyFastPairDataProvider.manageFastPairAccount(requestParcel);
+            return;
+        }
+        throw new IllegalStateException("No ProxyFastPairDataProvider yet constructed");
+    }
+
+    /**
+     * Uploads the device info to Fast Pair account.
+     *
+     * @throws IllegalStateException If ProxyFastPairDataProvider is not available.
+     */
+    public void upload(Account account, FastPairUploadInfo uploadInfo) {
+        if (mProxyFastPairDataProvider != null) {
+            FastPairManageAccountDeviceRequestParcel requestParcel =
+                    new FastPairManageAccountDeviceRequestParcel();
+            requestParcel.account = account;
+            requestParcel.requestType = FastPairDataProviderService.MANAGE_REQUEST_ADD;
+            requestParcel.accountKeyDeviceMetadata =
+                    Utils.convertToFastPairAccountKeyDeviceMetadata(uploadInfo);
+            mProxyFastPairDataProvider.manageFastPairAccountDevice(requestParcel);
+            return;
+        }
+        throw new IllegalStateException("No ProxyFastPairDataProvider yet constructed");
+    }
+
+    /**
+     * Get recognized device from bloom filter.
+     */
+    public Data.FastPairDeviceWithAccountKey getRecognizedDevice(BloomFilter bloomFilter,
+            byte[] salt) {
+        return Data.FastPairDeviceWithAccountKey.newBuilder().build();
+    }
+
+    /**
+     * Loads FastPair device accountKeys for a given account, but not other detailed fields.
+     *
+     * @throws IllegalStateException If ProxyFastPairDataProvider is not available.
+     */
+    public List<Data.FastPairDeviceWithAccountKey> loadFastPairDeviceWithAccountKey(
+            Account account) {
+        return loadFastPairDeviceWithAccountKey(account, new ArrayList<byte[]>(0));
+    }
+
+    /**
+     * Loads FastPair devices for a list of accountKeys of a given account.
+     *
+     * @param account The account of the FastPair devices.
+     * @param deviceAccountKeys The allow list of FastPair devices if it is not empty. Otherwise,
+     *                    the function returns accountKeys of all FastPair devices under the
+     *                    account, without detailed fields.
+     *
+     * @throws IllegalStateException If ProxyFastPairDataProvider is not available.
+     */
+    public List<Data.FastPairDeviceWithAccountKey> loadFastPairDeviceWithAccountKey(
+            Account account, List<byte[]> deviceAccountKeys) {
+        if (mProxyFastPairDataProvider != null) {
+            FastPairAccountDevicesMetadataRequestParcel requestParcel =
+                    new FastPairAccountDevicesMetadataRequestParcel();
+            requestParcel.account = account;
+            requestParcel.deviceAccountKeys = new ByteArrayParcel[deviceAccountKeys.size()];
+            int i = 0;
+            for (byte[] deviceAccountKey : deviceAccountKeys) {
+                requestParcel.deviceAccountKeys[i] = new ByteArrayParcel();
+                requestParcel.deviceAccountKeys[i].byteArray = deviceAccountKey;
+                i = i + 1;
+            }
+            return Utils.convertToFastPairDevicesWithAccountKey(
+                    mProxyFastPairDataProvider.loadFastPairAccountDevicesMetadata(requestParcel));
+        }
+        throw new IllegalStateException("No ProxyFastPairDataProvider yet constructed");
+    }
+
+    /**
+     * Loads FastPair Eligible Accounts.
+     *
+     * @throws IllegalStateException If ProxyFastPairDataProvider is not available.
+     */
+    public List<Account> loadFastPairEligibleAccounts() {
+        if (mProxyFastPairDataProvider != null) {
+            FastPairEligibleAccountsRequestParcel requestParcel =
+                    new FastPairEligibleAccountsRequestParcel();
+            return Utils.convertToAccountList(
+                    mProxyFastPairDataProvider.loadFastPairEligibleAccounts(requestParcel));
+        }
+        throw new IllegalStateException("No ProxyFastPairDataProvider yet constructed");
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/PrivacyFilter.java b/nearby/service/java/com/android/server/nearby/provider/PrivacyFilter.java
new file mode 100644
index 0000000..5c37f68
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/PrivacyFilter.java
@@ -0,0 +1,37 @@
+/*
+ * 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 android.annotation.Nullable;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.ScanRequest;
+
+/**
+ * Class strips out privacy sensitive data before delivering the callbacks to client.
+ */
+public class PrivacyFilter {
+
+    /**
+     * Strips sensitive data from {@link NearbyDeviceParcelable} according to
+     * different {@link android.nearby.ScanRequest.ScanType}s.
+     */
+    @Nullable
+    public static NearbyDeviceParcelable filter(@ScanRequest.ScanType int scanType,
+            NearbyDeviceParcelable scanResult) {
+        return scanResult;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/ProxyFastPairDataProvider.java b/nearby/service/java/com/android/server/nearby/provider/ProxyFastPairDataProvider.java
new file mode 100644
index 0000000..f0ade6c
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/ProxyFastPairDataProvider.java
@@ -0,0 +1,307 @@
+/*
+ * 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 android.annotation.Nullable;
+import android.content.Context;
+import android.nearby.aidl.FastPairAccountDevicesMetadataRequestParcel;
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataRequestParcel;
+import android.nearby.aidl.FastPairEligibleAccountParcel;
+import android.nearby.aidl.FastPairEligibleAccountsRequestParcel;
+import android.nearby.aidl.FastPairManageAccountDeviceRequestParcel;
+import android.nearby.aidl.FastPairManageAccountRequestParcel;
+import android.nearby.aidl.IFastPairAccountDevicesMetadataCallback;
+import android.nearby.aidl.IFastPairAntispoofKeyDeviceMetadataCallback;
+import android.nearby.aidl.IFastPairDataProvider;
+import android.nearby.aidl.IFastPairEligibleAccountsCallback;
+import android.nearby.aidl.IFastPairManageAccountCallback;
+import android.nearby.aidl.IFastPairManageAccountDeviceCallback;
+import android.os.IBinder;
+import android.os.RemoteException;
+
+import androidx.annotation.WorkerThread;
+
+import com.android.server.nearby.common.servicemonitor.CurrentUserServiceProvider;
+import com.android.server.nearby.common.servicemonitor.CurrentUserServiceProvider.BoundServiceInfo;
+import com.android.server.nearby.common.servicemonitor.ServiceMonitor;
+import com.android.server.nearby.common.servicemonitor.ServiceMonitor.ServiceListener;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Proxy for IFastPairDataProvider implementations.
+ */
+public class ProxyFastPairDataProvider implements ServiceListener<BoundServiceInfo> {
+
+    private static final int TIME_OUT_MILLIS = 10000;
+
+    /**
+     * Creates and registers this proxy. If no suitable service is available for the proxy, returns
+     * null.
+     */
+    @Nullable
+    public static ProxyFastPairDataProvider create(Context context, String action) {
+        ProxyFastPairDataProvider proxy = new ProxyFastPairDataProvider(context, action);
+        if (proxy.checkServiceResolves()) {
+            return proxy;
+        } else {
+            return null;
+        }
+    }
+
+    private final ServiceMonitor mServiceMonitor;
+
+    private ProxyFastPairDataProvider(Context context, String action) {
+        // safe to use direct executor since our locks are not acquired in a code path invoked by
+        // our owning provider
+
+        mServiceMonitor = ServiceMonitor.create(context, "FAST_PAIR_DATA_PROVIDER",
+                CurrentUserServiceProvider.create(context, action), this);
+    }
+
+    private boolean checkServiceResolves() {
+        return mServiceMonitor.checkServiceResolves();
+    }
+
+    /**
+     * User service watch to connect to actually services implemented by OEMs.
+     */
+    public void register() {
+        mServiceMonitor.register();
+    }
+
+    // Fast Pair Data Provider doesn't maintain a long running state.
+    // Therefore, it doesn't need setup at bind time.
+    @Override
+    public void onBind(IBinder binder, BoundServiceInfo boundServiceInfo) throws RemoteException {
+    }
+
+    // Fast Pair Data Provider doesn't maintain a long running state.
+    // Therefore, it doesn't need tear down at unbind time.
+    @Override
+    public void onUnbind() {
+    }
+
+    /**
+     * Invokes system api loadFastPairEligibleAccounts.
+     *
+     * @return an array of acccounts and their opt in status.
+     */
+    @WorkerThread
+    @Nullable
+    public FastPairEligibleAccountParcel[] loadFastPairEligibleAccounts(
+            FastPairEligibleAccountsRequestParcel requestParcel) {
+        final CountDownLatch waitForCompletionLatch = new CountDownLatch(1);
+        final AtomicReference<FastPairEligibleAccountParcel[]> response = new AtomicReference<>();
+        mServiceMonitor.runOnBinder(new ServiceMonitor.BinderOperation() {
+            @Override
+            public void run(IBinder binder) throws RemoteException {
+                IFastPairDataProvider provider = IFastPairDataProvider.Stub.asInterface(binder);
+                IFastPairEligibleAccountsCallback callback =
+                        new IFastPairEligibleAccountsCallback.Stub() {
+                            public void onFastPairEligibleAccountsReceived(
+                                    FastPairEligibleAccountParcel[] accountParcels) {
+                                response.set(accountParcels);
+                                waitForCompletionLatch.countDown();
+                            }
+
+                            public void onError(int code, String message) {
+                                waitForCompletionLatch.countDown();
+                            }
+                        };
+                provider.loadFastPairEligibleAccounts(requestParcel, callback);
+            }
+
+            @Override
+            public void onError() {
+                waitForCompletionLatch.countDown();
+            }
+        });
+        try {
+            waitForCompletionLatch.await(TIME_OUT_MILLIS, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            // skip.
+        }
+        return response.get();
+    }
+
+    /**
+     * Invokes system api manageFastPairAccount to opt in account, or opt out account.
+     */
+    @WorkerThread
+    public void manageFastPairAccount(FastPairManageAccountRequestParcel requestParcel) {
+        final CountDownLatch waitForCompletionLatch = new CountDownLatch(1);
+        mServiceMonitor.runOnBinder(new ServiceMonitor.BinderOperation() {
+            @Override
+            public void run(IBinder binder) throws RemoteException {
+                IFastPairDataProvider provider = IFastPairDataProvider.Stub.asInterface(binder);
+                IFastPairManageAccountCallback callback =
+                        new IFastPairManageAccountCallback.Stub() {
+                            public void onSuccess() {
+                                waitForCompletionLatch.countDown();
+                            }
+
+                            public void onError(int code, String message) {
+                                waitForCompletionLatch.countDown();
+                            }
+                        };
+                provider.manageFastPairAccount(requestParcel, callback);
+            }
+
+            @Override
+            public void onError() {
+                waitForCompletionLatch.countDown();
+            }
+        });
+        try {
+            waitForCompletionLatch.await(TIME_OUT_MILLIS, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            // skip.
+        }
+        return;
+    }
+
+    /**
+     * Invokes system api manageFastPairAccountDevice to add or remove a device from a Fast Pair
+     * account.
+     */
+    @WorkerThread
+    public void manageFastPairAccountDevice(
+            FastPairManageAccountDeviceRequestParcel requestParcel) {
+        final CountDownLatch waitForCompletionLatch = new CountDownLatch(1);
+        mServiceMonitor.runOnBinder(new ServiceMonitor.BinderOperation() {
+            @Override
+            public void run(IBinder binder) throws RemoteException {
+                IFastPairDataProvider provider = IFastPairDataProvider.Stub.asInterface(binder);
+                IFastPairManageAccountDeviceCallback callback =
+                        new IFastPairManageAccountDeviceCallback.Stub() {
+                            public void onSuccess() {
+                                waitForCompletionLatch.countDown();
+                            }
+
+                            public void onError(int code, String message) {
+                                waitForCompletionLatch.countDown();
+                            }
+                        };
+                provider.manageFastPairAccountDevice(requestParcel, callback);
+            }
+
+            @Override
+            public void onError() {
+                waitForCompletionLatch.countDown();
+            }
+        });
+        try {
+            waitForCompletionLatch.await(TIME_OUT_MILLIS, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            // skip.
+        }
+        return;
+    }
+
+    /**
+     * Invokes system api loadFastPairAntispoofKeyDeviceMetadata.
+     *
+     * @return the Fast Pair AntispoofKeyDeviceMetadata of a given device.
+     */
+    @WorkerThread
+    @Nullable
+    FastPairAntispoofKeyDeviceMetadataParcel loadFastPairAntispoofKeyDeviceMetadata(
+            FastPairAntispoofKeyDeviceMetadataRequestParcel requestParcel) {
+        final CountDownLatch waitForCompletionLatch = new CountDownLatch(1);
+        final AtomicReference<FastPairAntispoofKeyDeviceMetadataParcel> response =
+                new AtomicReference<>();
+        mServiceMonitor.runOnBinder(new ServiceMonitor.BinderOperation() {
+            @Override
+            public void run(IBinder binder) throws RemoteException {
+                IFastPairDataProvider provider = IFastPairDataProvider.Stub.asInterface(binder);
+                IFastPairAntispoofKeyDeviceMetadataCallback callback =
+                        new IFastPairAntispoofKeyDeviceMetadataCallback.Stub() {
+                            public void onFastPairAntispoofKeyDeviceMetadataReceived(
+                                    FastPairAntispoofKeyDeviceMetadataParcel metadata) {
+                                response.set(metadata);
+                                waitForCompletionLatch.countDown();
+                            }
+
+                            public void onError(int code, String message) {
+                                waitForCompletionLatch.countDown();
+                            }
+                        };
+                provider.loadFastPairAntispoofKeyDeviceMetadata(requestParcel, callback);
+            }
+
+            @Override
+            public void onError() {
+                waitForCompletionLatch.countDown();
+            }
+        });
+        try {
+            waitForCompletionLatch.await(TIME_OUT_MILLIS, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            // skip.
+        }
+        return response.get();
+    }
+
+    /**
+     * Invokes loadFastPairAccountDevicesMetadata.
+     *
+     * @return the metadata of Fast Pair devices that are associated with a given account.
+     */
+    @WorkerThread
+    @Nullable
+    FastPairAccountKeyDeviceMetadataParcel[] loadFastPairAccountDevicesMetadata(
+            FastPairAccountDevicesMetadataRequestParcel requestParcel) {
+        final CountDownLatch waitForCompletionLatch = new CountDownLatch(1);
+        final AtomicReference<FastPairAccountKeyDeviceMetadataParcel[]> response =
+                new AtomicReference<>();
+        mServiceMonitor.runOnBinder(new ServiceMonitor.BinderOperation() {
+            @Override
+            public void run(IBinder binder) throws RemoteException {
+                IFastPairDataProvider provider = IFastPairDataProvider.Stub.asInterface(binder);
+                IFastPairAccountDevicesMetadataCallback callback =
+                        new IFastPairAccountDevicesMetadataCallback.Stub() {
+                            public void onFastPairAccountDevicesMetadataReceived(
+                                    FastPairAccountKeyDeviceMetadataParcel[] metadatas) {
+                                response.set(metadatas);
+                                waitForCompletionLatch.countDown();
+                            }
+
+                            public void onError(int code, String message) {
+                                waitForCompletionLatch.countDown();
+                            }
+                        };
+                provider.loadFastPairAccountDevicesMetadata(requestParcel, callback);
+            }
+
+            @Override
+            public void onError() {
+                waitForCompletionLatch.countDown();
+            }
+        });
+        try {
+            waitForCompletionLatch.await(TIME_OUT_MILLIS, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            // skip.
+        }
+        return response.get();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/provider/Utils.java b/nearby/service/java/com/android/server/nearby/provider/Utils.java
new file mode 100644
index 0000000..22e31cd
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/provider/Utils.java
@@ -0,0 +1,601 @@
+/*
+ * 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 android.accounts.Account;
+import android.annotation.Nullable;
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairDeviceMetadataParcel;
+import android.nearby.aidl.FastPairDiscoveryItemParcel;
+import android.nearby.aidl.FastPairEligibleAccountParcel;
+
+import com.android.server.nearby.fastpair.footprint.FastPairUploadInfo;
+
+import com.google.protobuf.ByteString;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import service.proto.Cache;
+import service.proto.Data;
+import service.proto.FastPairString.FastPairStrings;
+import service.proto.Rpcs;
+
+/**
+ * Utility functions to convert between different data classes.
+ */
+class Utils {
+
+    static List<Data.FastPairDeviceWithAccountKey> convertToFastPairDevicesWithAccountKey(
+            @Nullable FastPairAccountKeyDeviceMetadataParcel[] metadataParcels) {
+        if (metadataParcels == null) {
+            return new ArrayList<Data.FastPairDeviceWithAccountKey>(0);
+        }
+
+        List<Data.FastPairDeviceWithAccountKey> fpDeviceList =
+                new ArrayList<>(metadataParcels.length);
+        for (FastPairAccountKeyDeviceMetadataParcel metadataParcel : metadataParcels) {
+            if (metadataParcel == null) {
+                continue;
+            }
+            Data.FastPairDeviceWithAccountKey.Builder fpDeviceBuilder =
+                    Data.FastPairDeviceWithAccountKey.newBuilder();
+            if (metadataParcel.deviceAccountKey != null) {
+                fpDeviceBuilder.setAccountKey(
+                        ByteString.copyFrom(metadataParcel.deviceAccountKey));
+            }
+            if (metadataParcel.sha256DeviceAccountKeyPublicAddress != null) {
+                fpDeviceBuilder.setSha256AccountKeyPublicAddress(
+                        ByteString.copyFrom(metadataParcel.sha256DeviceAccountKeyPublicAddress));
+            }
+
+            Cache.StoredDiscoveryItem.Builder storedDiscoveryItemBuilder =
+                    Cache.StoredDiscoveryItem.newBuilder();
+
+            if (metadataParcel.discoveryItem != null) {
+                if (metadataParcel.discoveryItem.actionUrl != null) {
+                    storedDiscoveryItemBuilder.setActionUrl(metadataParcel.discoveryItem.actionUrl);
+                }
+                Cache.ResolvedUrlType urlType = Cache.ResolvedUrlType.forNumber(
+                        metadataParcel.discoveryItem.actionUrlType);
+                if (urlType != null) {
+                    storedDiscoveryItemBuilder.setActionUrlType(urlType);
+                }
+                if (metadataParcel.discoveryItem.appName != null) {
+                    storedDiscoveryItemBuilder.setAppName(metadataParcel.discoveryItem.appName);
+                }
+                Cache.DiscoveryAttachmentType attachmentType =
+                        Cache.DiscoveryAttachmentType.forNumber(
+                                metadataParcel.discoveryItem.attachmentType);
+                if (attachmentType != null) {
+                    storedDiscoveryItemBuilder.setAttachmentType(attachmentType);
+                }
+                if (metadataParcel.discoveryItem.authenticationPublicKeySecp256r1 != null) {
+                    storedDiscoveryItemBuilder.setAuthenticationPublicKeySecp256R1(
+                            ByteString.copyFrom(
+                                    metadataParcel.discoveryItem.authenticationPublicKeySecp256r1));
+                }
+                if (metadataParcel.discoveryItem.bleRecordBytes != null) {
+                    storedDiscoveryItemBuilder.setBleRecordBytes(
+                            ByteString.copyFrom(metadataParcel.discoveryItem.bleRecordBytes));
+                }
+                Cache.StoredDiscoveryItem.DebugMessageCategory debugMessageCategory =
+                        Cache.StoredDiscoveryItem.DebugMessageCategory.forNumber(
+                                metadataParcel.discoveryItem.debugCategory);
+                if (debugMessageCategory != null) {
+                    storedDiscoveryItemBuilder.setDebugCategory(debugMessageCategory);
+                }
+                if (metadataParcel.discoveryItem.debugMessage != null) {
+                    storedDiscoveryItemBuilder.setDebugMessage(
+                            metadataParcel.discoveryItem.debugMessage);
+                }
+                if (metadataParcel.discoveryItem.description != null) {
+                    storedDiscoveryItemBuilder.setDescription(
+                            metadataParcel.discoveryItem.description);
+                }
+                if (metadataParcel.discoveryItem.deviceName != null) {
+                    storedDiscoveryItemBuilder.setDeviceName(
+                            metadataParcel.discoveryItem.deviceName);
+                }
+                if (metadataParcel.discoveryItem.displayUrl != null) {
+                    storedDiscoveryItemBuilder.setDisplayUrl(
+                            metadataParcel.discoveryItem.displayUrl);
+                }
+                if (metadataParcel.discoveryItem.entityId != null) {
+                    storedDiscoveryItemBuilder.setEntityId(
+                            metadataParcel.discoveryItem.entityId);
+                }
+                if (metadataParcel.discoveryItem.featureGraphicUrl != null) {
+                    storedDiscoveryItemBuilder.setFeatureGraphicUrl(
+                            metadataParcel.discoveryItem.featureGraphicUrl);
+                }
+                storedDiscoveryItemBuilder.setFirstObservationTimestampMillis(
+                        metadataParcel.discoveryItem.firstObservationTimestampMillis);
+                if (metadataParcel.discoveryItem.groupId != null) {
+                    storedDiscoveryItemBuilder.setGroupId(metadataParcel.discoveryItem.groupId);
+                }
+                if (metadataParcel.discoveryItem.iconFifeUrl != null) {
+                    storedDiscoveryItemBuilder.setIconFifeUrl(
+                            metadataParcel.discoveryItem.iconFifeUrl);
+                }
+                if (metadataParcel.discoveryItem.iconPng != null) {
+                    storedDiscoveryItemBuilder.setIconPng(
+                            ByteString.copyFrom(metadataParcel.discoveryItem.iconPng));
+                }
+                if (metadataParcel.discoveryItem.id != null) {
+                    storedDiscoveryItemBuilder.setId(metadataParcel.discoveryItem.id);
+                }
+                storedDiscoveryItemBuilder.setLastObservationTimestampMillis(
+                        metadataParcel.discoveryItem.lastObservationTimestampMillis);
+                Cache.StoredDiscoveryItem.ExperienceType experienceType =
+                        Cache.StoredDiscoveryItem.ExperienceType.forNumber(
+                                metadataParcel.discoveryItem.lastUserExperience);
+                if (experienceType != null) {
+                    storedDiscoveryItemBuilder.setLastUserExperience(experienceType);
+                }
+                storedDiscoveryItemBuilder.setLostMillis(metadataParcel.discoveryItem.lostMillis);
+                if (metadataParcel.discoveryItem.macAddress != null) {
+                    storedDiscoveryItemBuilder.setMacAddress(
+                            metadataParcel.discoveryItem.macAddress);
+                }
+                if (metadataParcel.discoveryItem.packageName != null) {
+                    storedDiscoveryItemBuilder.setPackageName(
+                            metadataParcel.discoveryItem.packageName);
+                }
+                storedDiscoveryItemBuilder.setPendingAppInstallTimestampMillis(
+                        metadataParcel.discoveryItem.pendingAppInstallTimestampMillis);
+                storedDiscoveryItemBuilder.setRssi(metadataParcel.discoveryItem.rssi);
+                Cache.StoredDiscoveryItem.State state =
+                        Cache.StoredDiscoveryItem.State.forNumber(
+                                metadataParcel.discoveryItem.state);
+                if (state != null) {
+                    storedDiscoveryItemBuilder.setState(state);
+                }
+                if (metadataParcel.discoveryItem.title != null) {
+                    storedDiscoveryItemBuilder.setTitle(metadataParcel.discoveryItem.title);
+                }
+                if (metadataParcel.discoveryItem.triggerId != null) {
+                    storedDiscoveryItemBuilder.setTriggerId(metadataParcel.discoveryItem.triggerId);
+                }
+                storedDiscoveryItemBuilder.setTxPower(metadataParcel.discoveryItem.txPower);
+                Cache.NearbyType type =
+                        Cache.NearbyType.forNumber(metadataParcel.discoveryItem.type);
+                if (type != null) {
+                    storedDiscoveryItemBuilder.setType(type);
+                }
+            }
+            if (metadataParcel.metadata != null) {
+                FastPairStrings.Builder stringsBuilder = FastPairStrings.newBuilder();
+                if (metadataParcel.metadata.assistantSetupHalfSheet != null) {
+                    stringsBuilder.setAssistantHalfSheetDescription(
+                            metadataParcel.metadata.assistantSetupHalfSheet);
+                }
+                if (metadataParcel.metadata.assistantSetupNotification != null) {
+                    stringsBuilder.setAssistantNotificationDescription(
+                            metadataParcel.metadata.assistantSetupNotification);
+                }
+                if (metadataParcel.metadata.confirmPinDescription != null) {
+                    stringsBuilder.setConfirmPinDescription(
+                            metadataParcel.metadata.confirmPinDescription);
+                }
+                if (metadataParcel.metadata.confirmPinTitle != null) {
+                    stringsBuilder.setConfirmPinTitle(
+                            metadataParcel.metadata.confirmPinTitle);
+                }
+                if (metadataParcel.metadata.connectSuccessCompanionAppInstalled != null) {
+                    stringsBuilder.setPairingFinishedCompanionAppInstalled(
+                            metadataParcel.metadata.connectSuccessCompanionAppInstalled);
+                }
+                if (metadataParcel.metadata.connectSuccessCompanionAppNotInstalled != null) {
+                    stringsBuilder.setPairingFinishedCompanionAppNotInstalled(
+                            metadataParcel.metadata.connectSuccessCompanionAppNotInstalled);
+                }
+                if (metadataParcel.metadata.failConnectGoToSettingsDescription != null) {
+                    stringsBuilder.setPairingFailDescription(
+                            metadataParcel.metadata.failConnectGoToSettingsDescription);
+                }
+                if (metadataParcel.metadata.fastPairTvConnectDeviceNoAccountDescription != null) {
+                    stringsBuilder.setFastPairTvConnectDeviceNoAccountDescription(
+                            metadataParcel.metadata.fastPairTvConnectDeviceNoAccountDescription);
+                }
+                if (metadataParcel.metadata.initialNotificationDescription != null) {
+                    stringsBuilder.setTapToPairWithAccount(
+                            metadataParcel.metadata.initialNotificationDescription);
+                }
+                if (metadataParcel.metadata.initialNotificationDescriptionNoAccount != null) {
+                    stringsBuilder.setTapToPairWithoutAccount(
+                            metadataParcel.metadata.initialNotificationDescriptionNoAccount);
+                }
+                if (metadataParcel.metadata.initialPairingDescription != null) {
+                    stringsBuilder.setInitialPairingDescription(
+                            metadataParcel.metadata.initialPairingDescription);
+                }
+                if (metadataParcel.metadata.retroactivePairingDescription != null) {
+                    stringsBuilder.setRetroactivePairingDescription(
+                            metadataParcel.metadata.retroactivePairingDescription);
+                }
+                if (metadataParcel.metadata.subsequentPairingDescription != null) {
+                    stringsBuilder.setSubsequentPairingDescription(
+                            metadataParcel.metadata.subsequentPairingDescription);
+                }
+                if (metadataParcel.metadata.syncContactsDescription != null) {
+                    stringsBuilder.setSyncContactsDescription(
+                            metadataParcel.metadata.syncContactsDescription);
+                }
+                if (metadataParcel.metadata.syncContactsTitle != null) {
+                    stringsBuilder.setSyncContactsTitle(
+                            metadataParcel.metadata.syncContactsTitle);
+                }
+                if (metadataParcel.metadata.syncSmsDescription != null) {
+                    stringsBuilder.setSyncSmsDescription(
+                            metadataParcel.metadata.syncSmsDescription);
+                }
+                if (metadataParcel.metadata.syncSmsTitle != null) {
+                    stringsBuilder.setSyncSmsTitle(metadataParcel.metadata.syncSmsTitle);
+                }
+                if (metadataParcel.metadata.waitLaunchCompanionAppDescription != null) {
+                    stringsBuilder.setWaitAppLaunchDescription(
+                            metadataParcel.metadata.waitLaunchCompanionAppDescription);
+                }
+                storedDiscoveryItemBuilder.setFastPairStrings(stringsBuilder.build());
+
+                Cache.FastPairInformation.Builder fpInformationBuilder =
+                        Cache.FastPairInformation.newBuilder();
+                Rpcs.TrueWirelessHeadsetImages.Builder imagesBuilder =
+                        Rpcs.TrueWirelessHeadsetImages.newBuilder();
+                if (metadataParcel.metadata.trueWirelessImageUrlCase != null) {
+                    imagesBuilder.setCaseUrl(metadataParcel.metadata.trueWirelessImageUrlCase);
+                }
+                if (metadataParcel.metadata.trueWirelessImageUrlLeftBud != null) {
+                    imagesBuilder.setLeftBudUrl(
+                            metadataParcel.metadata.trueWirelessImageUrlLeftBud);
+                }
+                if (metadataParcel.metadata.trueWirelessImageUrlRightBud != null) {
+                    imagesBuilder.setRightBudUrl(
+                            metadataParcel.metadata.trueWirelessImageUrlRightBud);
+                }
+                fpInformationBuilder.setTrueWirelessImages(imagesBuilder.build());
+                Rpcs.DeviceType deviceType =
+                        Rpcs.DeviceType.forNumber(metadataParcel.metadata.deviceType);
+                if (deviceType != null) {
+                    fpInformationBuilder.setDeviceType(deviceType);
+                }
+
+                storedDiscoveryItemBuilder.setFastPairInformation(fpInformationBuilder.build());
+            }
+            fpDeviceBuilder.setDiscoveryItem(storedDiscoveryItemBuilder.build());
+            fpDeviceList.add(fpDeviceBuilder.build());
+        }
+        return fpDeviceList;
+    }
+
+    static List<Account> convertToAccountList(
+            @Nullable FastPairEligibleAccountParcel[] accountParcels) {
+        if (accountParcels == null) {
+            return new ArrayList<Account>(0);
+        }
+        List<Account> accounts = new ArrayList<Account>(accountParcels.length);
+        for (FastPairEligibleAccountParcel parcel : accountParcels) {
+            if (parcel != null && parcel.account != null) {
+                accounts.add(parcel.account);
+            }
+        }
+        return accounts;
+    }
+
+    private static @Nullable Rpcs.Device convertToDevice(
+            FastPairAntispoofKeyDeviceMetadataParcel metadata) {
+
+        Rpcs.Device.Builder deviceBuilder = Rpcs.Device.newBuilder();
+        if (metadata.antispoofPublicKey != null) {
+            deviceBuilder.setAntiSpoofingKeyPair(Rpcs.AntiSpoofingKeyPair.newBuilder()
+                    .setPublicKey(ByteString.copyFrom(metadata.antispoofPublicKey))
+                    .build());
+        }
+        if (metadata.deviceMetadata != null) {
+            Rpcs.TrueWirelessHeadsetImages.Builder imagesBuilder =
+                    Rpcs.TrueWirelessHeadsetImages.newBuilder();
+            if (metadata.deviceMetadata.trueWirelessImageUrlLeftBud != null) {
+                imagesBuilder.setLeftBudUrl(metadata.deviceMetadata.trueWirelessImageUrlLeftBud);
+            }
+            if (metadata.deviceMetadata.trueWirelessImageUrlRightBud != null) {
+                imagesBuilder.setRightBudUrl(metadata.deviceMetadata.trueWirelessImageUrlRightBud);
+            }
+            if (metadata.deviceMetadata.trueWirelessImageUrlCase != null) {
+                imagesBuilder.setCaseUrl(metadata.deviceMetadata.trueWirelessImageUrlCase);
+            }
+            deviceBuilder.setTrueWirelessImages(imagesBuilder.build());
+            if (metadata.deviceMetadata.imageUrl != null) {
+                deviceBuilder.setImageUrl(metadata.deviceMetadata.imageUrl);
+            }
+            if (metadata.deviceMetadata.intentUri != null) {
+                deviceBuilder.setIntentUri(metadata.deviceMetadata.intentUri);
+            }
+            if (metadata.deviceMetadata.name != null) {
+                deviceBuilder.setName(metadata.deviceMetadata.name);
+            }
+            Rpcs.DeviceType deviceType =
+                    Rpcs.DeviceType.forNumber(metadata.deviceMetadata.deviceType);
+            if (deviceType != null) {
+                deviceBuilder.setDeviceType(deviceType);
+            }
+            deviceBuilder.setBleTxPower(metadata.deviceMetadata.bleTxPower)
+                    .setTriggerDistance(metadata.deviceMetadata.triggerDistance);
+        }
+
+        return deviceBuilder.build();
+    }
+
+    private static @Nullable ByteString convertToImage(
+            FastPairAntispoofKeyDeviceMetadataParcel metadata) {
+        if (metadata.deviceMetadata == null || metadata.deviceMetadata.image == null) {
+            return null;
+        }
+
+        return ByteString.copyFrom(metadata.deviceMetadata.image);
+    }
+
+    private static @Nullable Rpcs.ObservedDeviceStrings
+            convertToObservedDeviceStrings(FastPairAntispoofKeyDeviceMetadataParcel metadata) {
+        if (metadata.deviceMetadata == null) {
+            return null;
+        }
+
+        Rpcs.ObservedDeviceStrings.Builder stringsBuilder = Rpcs.ObservedDeviceStrings.newBuilder();
+        if (metadata.deviceMetadata.assistantSetupHalfSheet != null) {
+            stringsBuilder
+                    .setAssistantSetupHalfSheet(metadata.deviceMetadata.assistantSetupHalfSheet);
+        }
+        if (metadata.deviceMetadata.assistantSetupNotification != null) {
+            stringsBuilder.setAssistantSetupNotification(
+                    metadata.deviceMetadata.assistantSetupNotification);
+        }
+        if (metadata.deviceMetadata.confirmPinDescription != null) {
+            stringsBuilder.setConfirmPinDescription(metadata.deviceMetadata.confirmPinDescription);
+        }
+        if (metadata.deviceMetadata.confirmPinTitle != null) {
+            stringsBuilder.setConfirmPinTitle(metadata.deviceMetadata.confirmPinTitle);
+        }
+        if (metadata.deviceMetadata.connectSuccessCompanionAppInstalled != null) {
+            stringsBuilder.setConnectSuccessCompanionAppInstalled(
+                    metadata.deviceMetadata.connectSuccessCompanionAppInstalled);
+        }
+        if (metadata.deviceMetadata.connectSuccessCompanionAppNotInstalled != null) {
+            stringsBuilder.setConnectSuccessCompanionAppNotInstalled(
+                    metadata.deviceMetadata.connectSuccessCompanionAppNotInstalled);
+        }
+        if (metadata.deviceMetadata.downloadCompanionAppDescription != null) {
+            stringsBuilder.setDownloadCompanionAppDescription(
+                    metadata.deviceMetadata.downloadCompanionAppDescription);
+        }
+        if (metadata.deviceMetadata.failConnectGoToSettingsDescription != null) {
+            stringsBuilder.setFailConnectGoToSettingsDescription(
+                    metadata.deviceMetadata.failConnectGoToSettingsDescription);
+        }
+        if (metadata.deviceMetadata.fastPairTvConnectDeviceNoAccountDescription != null) {
+            stringsBuilder.setFastPairTvConnectDeviceNoAccountDescription(
+                    metadata.deviceMetadata.fastPairTvConnectDeviceNoAccountDescription);
+        }
+        if (metadata.deviceMetadata.initialNotificationDescription != null) {
+            stringsBuilder.setInitialNotificationDescription(
+                    metadata.deviceMetadata.initialNotificationDescription);
+        }
+        if (metadata.deviceMetadata.initialNotificationDescriptionNoAccount != null) {
+            stringsBuilder.setInitialNotificationDescriptionNoAccount(
+                    metadata.deviceMetadata.initialNotificationDescriptionNoAccount);
+        }
+        if (metadata.deviceMetadata.initialPairingDescription != null) {
+            stringsBuilder.setInitialPairingDescription(
+                    metadata.deviceMetadata.initialPairingDescription);
+        }
+        if (metadata.deviceMetadata.locale != null) {
+            stringsBuilder.setLocale(metadata.deviceMetadata.locale);
+        }
+        if (metadata.deviceMetadata.openCompanionAppDescription != null) {
+            stringsBuilder.setOpenCompanionAppDescription(
+                    metadata.deviceMetadata.openCompanionAppDescription);
+        }
+        if (metadata.deviceMetadata.retroactivePairingDescription != null) {
+            stringsBuilder.setRetroactivePairingDescription(
+                    metadata.deviceMetadata.retroactivePairingDescription);
+        }
+        if (metadata.deviceMetadata.subsequentPairingDescription != null) {
+            stringsBuilder.setSubsequentPairingDescription(
+                    metadata.deviceMetadata.subsequentPairingDescription);
+        }
+        if (metadata.deviceMetadata.syncContactsDescription != null) {
+            stringsBuilder.setSyncContactsDescription(
+                    metadata.deviceMetadata.syncContactsDescription);
+        }
+        if (metadata.deviceMetadata.syncContactsTitle != null) {
+            stringsBuilder.setSyncContactsTitle(
+                    metadata.deviceMetadata.syncContactsTitle);
+        }
+        if (metadata.deviceMetadata.syncSmsDescription != null) {
+            stringsBuilder.setSyncSmsDescription(
+                    metadata.deviceMetadata.syncSmsDescription);
+        }
+        if (metadata.deviceMetadata.syncSmsTitle != null) {
+            stringsBuilder.setSyncSmsTitle(
+                    metadata.deviceMetadata.syncSmsTitle);
+        }
+        if (metadata.deviceMetadata.unableToConnectDescription != null) {
+            stringsBuilder.setUnableToConnectDescription(
+                    metadata.deviceMetadata.unableToConnectDescription);
+        }
+        if (metadata.deviceMetadata.unableToConnectTitle != null) {
+            stringsBuilder.setUnableToConnectTitle(
+                    metadata.deviceMetadata.unableToConnectTitle);
+        }
+        if (metadata.deviceMetadata.updateCompanionAppDescription != null) {
+            stringsBuilder.setUpdateCompanionAppDescription(
+                    metadata.deviceMetadata.updateCompanionAppDescription);
+        }
+        if (metadata.deviceMetadata.waitLaunchCompanionAppDescription != null) {
+            stringsBuilder.setWaitLaunchCompanionAppDescription(
+                    metadata.deviceMetadata.waitLaunchCompanionAppDescription);
+        }
+
+        return stringsBuilder.build();
+    }
+
+    static @Nullable Rpcs.GetObservedDeviceResponse
+            convertToGetObservedDeviceResponse(
+                    @Nullable FastPairAntispoofKeyDeviceMetadataParcel metadata) {
+        if (metadata == null) {
+            return null;
+        }
+
+        Rpcs.GetObservedDeviceResponse.Builder responseBuilder =
+                Rpcs.GetObservedDeviceResponse.newBuilder();
+
+        Rpcs.Device device = convertToDevice(metadata);
+        if (device != null) {
+            responseBuilder.setDevice(device);
+        }
+        ByteString image = convertToImage(metadata);
+        if (image != null) {
+            responseBuilder.setImage(image);
+        }
+        Rpcs.ObservedDeviceStrings strings = convertToObservedDeviceStrings(metadata);
+        if (strings != null) {
+            responseBuilder.setStrings(strings);
+        }
+
+        return responseBuilder.build();
+    }
+
+    static @Nullable FastPairAccountKeyDeviceMetadataParcel
+            convertToFastPairAccountKeyDeviceMetadata(
+            @Nullable FastPairUploadInfo uploadInfo) {
+        if (uploadInfo == null) {
+            return null;
+        }
+
+        FastPairAccountKeyDeviceMetadataParcel accountKeyDeviceMetadataParcel =
+                new FastPairAccountKeyDeviceMetadataParcel();
+        if (uploadInfo.getAccountKey() != null) {
+            accountKeyDeviceMetadataParcel.deviceAccountKey =
+                    uploadInfo.getAccountKey().toByteArray();
+        }
+        if (uploadInfo.getSha256AccountKeyPublicAddress() != null) {
+            accountKeyDeviceMetadataParcel.sha256DeviceAccountKeyPublicAddress =
+                    uploadInfo.getSha256AccountKeyPublicAddress().toByteArray();
+        }
+        if (uploadInfo.getStoredDiscoveryItem() != null) {
+            accountKeyDeviceMetadataParcel.metadata =
+                    convertToFastPairDeviceMetadata(uploadInfo.getStoredDiscoveryItem());
+            accountKeyDeviceMetadataParcel.discoveryItem =
+                    convertToFastPairDiscoveryItem(uploadInfo.getStoredDiscoveryItem());
+        }
+
+        return accountKeyDeviceMetadataParcel;
+    }
+
+    private static @Nullable FastPairDiscoveryItemParcel
+            convertToFastPairDiscoveryItem(Cache.StoredDiscoveryItem storedDiscoveryItem) {
+        FastPairDiscoveryItemParcel discoveryItemParcel = new FastPairDiscoveryItemParcel();
+        discoveryItemParcel.actionUrl = storedDiscoveryItem.getActionUrl();
+        discoveryItemParcel.actionUrlType = storedDiscoveryItem.getActionUrlType().getNumber();
+        discoveryItemParcel.appName = storedDiscoveryItem.getAppName();
+        discoveryItemParcel.attachmentType = storedDiscoveryItem.getAttachmentType().getNumber();
+        discoveryItemParcel.authenticationPublicKeySecp256r1 =
+                storedDiscoveryItem.getAuthenticationPublicKeySecp256R1().toByteArray();
+        discoveryItemParcel.bleRecordBytes = storedDiscoveryItem.getBleRecordBytes().toByteArray();
+        discoveryItemParcel.debugCategory = storedDiscoveryItem.getDebugCategory().getNumber();
+        discoveryItemParcel.debugMessage = storedDiscoveryItem.getDebugMessage();
+        discoveryItemParcel.description = storedDiscoveryItem.getDescription();
+        discoveryItemParcel.deviceName = storedDiscoveryItem.getDeviceName();
+        discoveryItemParcel.displayUrl = storedDiscoveryItem.getDisplayUrl();
+        discoveryItemParcel.entityId = storedDiscoveryItem.getEntityId();
+        discoveryItemParcel.featureGraphicUrl = storedDiscoveryItem.getFeatureGraphicUrl();
+        discoveryItemParcel.firstObservationTimestampMillis =
+                storedDiscoveryItem.getFirstObservationTimestampMillis();
+        discoveryItemParcel.groupId = storedDiscoveryItem.getGroupId();
+        discoveryItemParcel.iconFifeUrl = storedDiscoveryItem.getIconFifeUrl();
+        discoveryItemParcel.iconPng = storedDiscoveryItem.getIconPng().toByteArray();
+        discoveryItemParcel.id = storedDiscoveryItem.getId();
+        discoveryItemParcel.lastObservationTimestampMillis =
+                storedDiscoveryItem.getLastObservationTimestampMillis();
+        discoveryItemParcel.lastUserExperience =
+                storedDiscoveryItem.getLastUserExperience().getNumber();
+        discoveryItemParcel.lostMillis = storedDiscoveryItem.getLostMillis();
+        discoveryItemParcel.macAddress = storedDiscoveryItem.getMacAddress();
+        discoveryItemParcel.packageName = storedDiscoveryItem.getPackageName();
+        discoveryItemParcel.pendingAppInstallTimestampMillis =
+                storedDiscoveryItem.getPendingAppInstallTimestampMillis();
+        discoveryItemParcel.rssi = storedDiscoveryItem.getRssi();
+        discoveryItemParcel.state = storedDiscoveryItem.getState().getNumber();
+        discoveryItemParcel.title = storedDiscoveryItem.getTitle();
+        discoveryItemParcel.triggerId = storedDiscoveryItem.getTriggerId();
+        discoveryItemParcel.txPower = storedDiscoveryItem.getTxPower();
+        discoveryItemParcel.type = storedDiscoveryItem.getType().getNumber();
+
+        return discoveryItemParcel;
+    }
+
+    /*  Do we upload these?
+        String downloadCompanionAppDescription =
+             bundle.getString("downloadCompanionAppDescription");
+        String locale = bundle.getString("locale");
+        String openCompanionAppDescription = bundle.getString("openCompanionAppDescription");
+        float triggerDistance = bundle.getFloat("triggerDistance");
+        String unableToConnectDescription = bundle.getString("unableToConnectDescription");
+        String unableToConnectTitle = bundle.getString("unableToConnectTitle");
+        String updateCompanionAppDescription = bundle.getString("updateCompanionAppDescription");
+    */
+    private static @Nullable FastPairDeviceMetadataParcel
+            convertToFastPairDeviceMetadata(Cache.StoredDiscoveryItem storedDiscoveryItem) {
+        FastPairStrings fpStrings = storedDiscoveryItem.getFastPairStrings();
+
+        FastPairDeviceMetadataParcel metadataParcel = new FastPairDeviceMetadataParcel();
+        metadataParcel.assistantSetupHalfSheet = fpStrings.getAssistantHalfSheetDescription();
+        metadataParcel.assistantSetupNotification = fpStrings.getAssistantNotificationDescription();
+        metadataParcel.confirmPinDescription = fpStrings.getConfirmPinDescription();
+        metadataParcel.confirmPinTitle = fpStrings.getConfirmPinTitle();
+        metadataParcel.connectSuccessCompanionAppInstalled =
+                fpStrings.getPairingFinishedCompanionAppInstalled();
+        metadataParcel.connectSuccessCompanionAppNotInstalled =
+                fpStrings.getPairingFinishedCompanionAppNotInstalled();
+        metadataParcel.failConnectGoToSettingsDescription = fpStrings.getPairingFailDescription();
+        metadataParcel.fastPairTvConnectDeviceNoAccountDescription =
+                fpStrings.getFastPairTvConnectDeviceNoAccountDescription();
+        metadataParcel.initialNotificationDescription = fpStrings.getTapToPairWithAccount();
+        metadataParcel.initialNotificationDescriptionNoAccount =
+                fpStrings.getTapToPairWithoutAccount();
+        metadataParcel.initialPairingDescription = fpStrings.getInitialPairingDescription();
+        metadataParcel.retroactivePairingDescription = fpStrings.getRetroactivePairingDescription();
+        metadataParcel.subsequentPairingDescription = fpStrings.getSubsequentPairingDescription();
+        metadataParcel.syncContactsDescription = fpStrings.getSyncContactsDescription();
+        metadataParcel.syncContactsTitle = fpStrings.getSyncContactsTitle();
+        metadataParcel.syncSmsDescription = fpStrings.getSyncSmsDescription();
+        metadataParcel.syncSmsTitle = fpStrings.getSyncSmsTitle();
+        metadataParcel.waitLaunchCompanionAppDescription = fpStrings.getWaitAppLaunchDescription();
+
+        Cache.FastPairInformation fpInformation = storedDiscoveryItem.getFastPairInformation();
+        metadataParcel.trueWirelessImageUrlCase =
+                fpInformation.getTrueWirelessImages().getCaseUrl();
+        metadataParcel.trueWirelessImageUrlLeftBud =
+                fpInformation.getTrueWirelessImages().getLeftBudUrl();
+        metadataParcel.trueWirelessImageUrlRightBud =
+                fpInformation.getTrueWirelessImages().getRightBudUrl();
+        metadataParcel.deviceType = fpInformation.getDeviceType().getNumber();
+
+        return metadataParcel;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/ArrayUtils.java b/nearby/service/java/com/android/server/nearby/util/ArrayUtils.java
new file mode 100644
index 0000000..599843c
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/ArrayUtils.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.util;
+
+import java.util.Arrays;
+
+/**
+ * ArrayUtils class that help manipulate array.
+ */
+public class ArrayUtils {
+    /** Concatenate N arrays of bytes into a single array. */
+    public static byte[] concatByteArrays(byte[]... arrays) {
+        // Degenerate case - no input provided.
+        if (arrays.length == 0) {
+            return new byte[0];
+        }
+
+        // Compute the total size.
+        int totalSize = 0;
+        for (int i = 0; i < arrays.length; i++) {
+            totalSize += arrays[i].length;
+        }
+
+        // Copy the arrays into the new array.
+        byte[] result = Arrays.copyOf(arrays[0], totalSize);
+        int pos = arrays[0].length;
+        for (int i = 1; i < arrays.length; i++) {
+            byte[] current = arrays[i];
+            System.arraycopy(current, 0, result, pos, current.length);
+            pos += current.length;
+        }
+        return result;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/DataUtils.java b/nearby/service/java/com/android/server/nearby/util/DataUtils.java
new file mode 100644
index 0000000..0b01bc0
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/DataUtils.java
@@ -0,0 +1,121 @@
+/*
+ * 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 androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import service.proto.Cache.ScanFastPairStoreItem;
+import service.proto.Cache.StoredDiscoveryItem;
+import service.proto.FastPairString.FastPairStrings;
+import service.proto.Rpcs.Device;
+import service.proto.Rpcs.GetObservedDeviceResponse;
+import service.proto.Rpcs.ObservedDeviceStrings;
+
+/**
+ * Utils class converts different data types {@link ScanFastPairStoreItem},
+ * {@link StoredDiscoveryItem} and {@link GetObservedDeviceResponse},
+ *
+ */
+public final class DataUtils {
+
+    /**
+     * Converts a {@link GetObservedDeviceResponse} to a {@link ScanFastPairStoreItem}.
+     */
+    public static ScanFastPairStoreItem toScanFastPairStoreItem(
+            GetObservedDeviceResponse observedDeviceResponse,
+            @NonNull String bleAddress, @Nullable String account) {
+        Device device = observedDeviceResponse.getDevice();
+        String deviceName = device.getName();
+        return ScanFastPairStoreItem.newBuilder()
+                .setAddress(bleAddress)
+                .setActionUrl(device.getIntentUri())
+                .setDeviceName(deviceName)
+                .setIconPng(observedDeviceResponse.getImage())
+                .setIconFifeUrl(device.getImageUrl())
+                .setAntiSpoofingPublicKey(device.getAntiSpoofingKeyPair().getPublicKey())
+                .setFastPairStrings(getFastPairStrings(observedDeviceResponse, deviceName, account))
+                .build();
+    }
+
+    /**
+     * Prints readable string for a {@link ScanFastPairStoreItem}.
+     */
+    public static String toString(ScanFastPairStoreItem item) {
+        return "ScanFastPairStoreItem=[address:" + item.getAddress()
+                + ", actionUr:" + item.getActionUrl()
+                + ", deviceName:" + item.getDeviceName()
+                + ", iconPng:" + item.getIconPng()
+                + ", iconFifeUrl:" + item.getIconFifeUrl()
+                + ", antiSpoofingKeyPair:" + item.getAntiSpoofingPublicKey()
+                + ", fastPairStrings:" + toString(item.getFastPairStrings())
+                + "]";
+    }
+
+    /**
+     * Prints readable string for a {@link FastPairStrings}
+     */
+    public static String toString(FastPairStrings fastPairStrings) {
+        return "FastPairStrings["
+                + "tapToPairWithAccount=" + fastPairStrings.getTapToPairWithAccount()
+                + ", tapToPairWithoutAccount=" + fastPairStrings.getTapToPairWithoutAccount()
+                + ", initialPairingDescription=" + fastPairStrings.getInitialPairingDescription()
+                + ", pairingFinishedCompanionAppInstalled="
+                + fastPairStrings.getPairingFinishedCompanionAppInstalled()
+                + ", pairingFinishedCompanionAppNotInstalled="
+                + fastPairStrings.getPairingFinishedCompanionAppNotInstalled()
+                + ", subsequentPairingDescription="
+                + fastPairStrings.getSubsequentPairingDescription()
+                + ", retroactivePairingDescription="
+                + fastPairStrings.getRetroactivePairingDescription()
+                + ", waitAppLaunchDescription=" + fastPairStrings.getWaitAppLaunchDescription()
+                + ", pairingFailDescription=" + fastPairStrings.getPairingFailDescription()
+                + ", assistantHalfSheetDescription="
+                + fastPairStrings.getAssistantHalfSheetDescription()
+                + ", assistantNotificationDescription="
+                + fastPairStrings.getAssistantNotificationDescription()
+                + ", fastPairTvConnectDeviceNoAccountDescription="
+                + fastPairStrings.getFastPairTvConnectDeviceNoAccountDescription()
+                + "]";
+    }
+
+    private static FastPairStrings getFastPairStrings(GetObservedDeviceResponse response,
+            String deviceName, @Nullable String account) {
+        ObservedDeviceStrings strings = response.getStrings();
+        return FastPairStrings.newBuilder()
+                .setTapToPairWithAccount(strings.getInitialNotificationDescription())
+                .setTapToPairWithoutAccount(
+                        strings.getInitialNotificationDescriptionNoAccount())
+                .setInitialPairingDescription(account == null
+                        ? strings.getInitialNotificationDescriptionNoAccount()
+                        : String.format(strings.getInitialPairingDescription(),
+                                deviceName, account))
+                .setPairingFinishedCompanionAppInstalled(
+                        strings.getConnectSuccessCompanionAppInstalled())
+                .setPairingFinishedCompanionAppNotInstalled(
+                        strings.getConnectSuccessCompanionAppNotInstalled())
+                .setSubsequentPairingDescription(strings.getSubsequentPairingDescription())
+                .setRetroactivePairingDescription(strings.getRetroactivePairingDescription())
+                .setWaitAppLaunchDescription(strings.getWaitLaunchCompanionAppDescription())
+                .setPairingFailDescription(strings.getFailConnectGoToSettingsDescription())
+                .setAssistantHalfSheetDescription(strings.getAssistantSetupHalfSheet())
+                .setAssistantNotificationDescription(strings.getAssistantSetupNotification())
+                .setFastPairTvConnectDeviceNoAccountDescription(
+                        strings.getFastPairTvConnectDeviceNoAccountDescription())
+                .build();
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/Environment.java b/nearby/service/java/com/android/server/nearby/util/Environment.java
new file mode 100644
index 0000000..d397862
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/Environment.java
@@ -0,0 +1,63 @@
+/*
+ * 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.util;
+
+import android.content.ApexEnvironment;
+import android.content.pm.ApplicationInfo;
+import android.os.UserHandle;
+
+import java.io.File;
+
+/**
+ * Provides function to make sure the function caller is from the same apex.
+ */
+public class Environment {
+    /**
+     * NEARBY apex name.
+     */
+    private static final String NEARBY_APEX_NAME = "com.android.tethering";
+
+    /**
+     * The path where the Nearby apex is mounted.
+     * Current value = "/apex/com.android.tethering"
+     */
+    private static final String NEARBY_APEX_PATH =
+            new File("/apex", NEARBY_APEX_NAME).getAbsolutePath();
+
+    /**
+     * Nearby shared folder.
+     */
+    public static File getNearbyDirectory() {
+        return ApexEnvironment.getApexEnvironment(NEARBY_APEX_NAME).getDeviceProtectedDataDir();
+    }
+
+    /**
+     * Nearby user specific folder.
+     */
+    public static File getNearbyDirectory(int userId) {
+        return ApexEnvironment.getApexEnvironment(NEARBY_APEX_NAME)
+                .getCredentialProtectedDataDirForUser(UserHandle.of(userId));
+    }
+
+    /**
+     * Returns true if the app is in the nearby apex, false otherwise.
+     * Checks if the app's path starts with "/apex/com.android.tethering".
+     */
+    public static boolean isAppInNearbyApex(ApplicationInfo appInfo) {
+        return appInfo.sourceDir.startsWith(NEARBY_APEX_PATH);
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/FastPairDecoder.java b/nearby/service/java/com/android/server/nearby/util/FastPairDecoder.java
new file mode 100644
index 0000000..6021ff6
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/FastPairDecoder.java
@@ -0,0 +1,258 @@
+/*
+ * 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 android.annotation.Nullable;
+import android.bluetooth.le.ScanRecord;
+import android.os.ParcelUuid;
+import android.util.SparseArray;
+
+import com.android.server.nearby.common.ble.BleFilter;
+import com.android.server.nearby.common.ble.BleRecord;
+
+import java.util.Arrays;
+
+/**
+ * Parses Fast Pair information out of {@link BleRecord}s.
+ *
+ * <p>There are 2 different packet formats that are supported, which is used can be determined by
+ * packet length:
+ *
+ * <p>For 3-byte packets, the full packet is the model ID.
+ *
+ * <p>For all other packets, the first byte is the header, followed by the model ID, followed by
+ * zero or more extra fields. Each field has its own header byte followed by the field value. The
+ * packet header is formatted as 0bVVVLLLLR (V = version, L = model ID length, R = reserved) and
+ * each extra field header is 0bLLLLTTTT (L = field length, T = field type).
+ */
+public class FastPairDecoder {
+
+    private static final int FIELD_TYPE_BLOOM_FILTER = 0;
+    private static final int FIELD_TYPE_BLOOM_FILTER_SALT = 1;
+    private static final int FIELD_TYPE_BLOOM_FILTER_NO_NOTIFICATION = 2;
+    private static final int FIELD_TYPE_BATTERY = 3;
+    private static final int FIELD_TYPE_BATTERY_NO_NOTIFICATION = 4;
+    public static final int FIELD_TYPE_CONNECTION_STATE = 5;
+    private static final int FIELD_TYPE_RANDOM_RESOLVABLE_DATA = 6;
+
+
+    /** FE2C is the 16-bit Service UUID. The rest is the base UUID. See BluetoothUuid (hidden). */
+    private static final ParcelUuid FAST_PAIR_SERVICE_PARCEL_UUID =
+            ParcelUuid.fromString("0000FE2C-0000-1000-8000-00805F9B34FB");
+
+    /** The filter you use to scan for Fast Pair BLE advertisements. */
+    public static final BleFilter FILTER =
+            new BleFilter.Builder().setServiceData(FAST_PAIR_SERVICE_PARCEL_UUID,
+                    new byte[0]).build();
+
+    // NOTE: Ensure that all bitmasks are always ints, not bytes so that bitshifting works correctly
+    // without needing worry about signing errors.
+    private static final int HEADER_VERSION_BITMASK = 0b11100000;
+    private static final int HEADER_LENGTH_BITMASK = 0b00011110;
+    private static final int HEADER_VERSION_OFFSET = 5;
+    private static final int HEADER_LENGTH_OFFSET = 1;
+
+    private static final int EXTRA_FIELD_LENGTH_BITMASK = 0b11110000;
+    private static final int EXTRA_FIELD_TYPE_BITMASK = 0b00001111;
+    private static final int EXTRA_FIELD_LENGTH_OFFSET = 4;
+    private static final int EXTRA_FIELD_TYPE_OFFSET = 0;
+
+    private static final int MIN_ID_LENGTH = 3;
+    private static final int MAX_ID_LENGTH = 14;
+    private static final int HEADER_INDEX = 0;
+    private static final int HEADER_LENGTH = 1;
+    private static final int FIELD_HEADER_LENGTH = 1;
+
+    // Not using java.util.IllegalFormatException because it is unchecked.
+    private static class IllegalFormatException extends Exception {
+        private IllegalFormatException(String message) {
+            super(message);
+        }
+    }
+
+    /**
+     * Gets model id data from broadcast
+     */
+    @Nullable
+    public static byte[] getModelId(@Nullable byte[] serviceData) {
+        if (serviceData == null) {
+            return null;
+        }
+
+        if (serviceData.length >= MIN_ID_LENGTH) {
+            if (serviceData.length == MIN_ID_LENGTH) {
+                // If the length == 3, all bytes are the ID. See flag docs for more about
+                // endianness.
+                return serviceData;
+            } else {
+                // Otherwise, the first byte is a header which contains the length of the big-endian
+                // model ID that follows. The model ID will be trimmed if it contains leading zeros.
+                int idIndex = 1;
+                int end = idIndex + getIdLength(serviceData);
+                while (serviceData[idIndex] == 0 && end - idIndex > MIN_ID_LENGTH) {
+                    idIndex++;
+                }
+                return Arrays.copyOfRange(serviceData, idIndex, end);
+            }
+        }
+        return null;
+    }
+
+    /** Gets the FastPair service data array if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getServiceDataArray(BleRecord bleRecord) {
+        return bleRecord.getServiceData(FAST_PAIR_SERVICE_PARCEL_UUID);
+    }
+
+    /** Gets the FastPair service data array if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getServiceDataArray(ScanRecord scanRecord) {
+        return scanRecord.getServiceData(FAST_PAIR_SERVICE_PARCEL_UUID);
+    }
+
+    /** Gets the bloom filter from the extra fields if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getBloomFilter(@Nullable byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BLOOM_FILTER);
+    }
+
+    /** Gets the bloom filter salt from the extra fields if available, otherwise returns null. */
+    @Nullable
+    public static byte[] getBloomFilterSalt(byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BLOOM_FILTER_SALT);
+    }
+
+    /**
+     * Gets the suppress notification with bloom filter from the extra fields if available,
+     * otherwise returns null.
+     */
+    @Nullable
+    public static byte[] getBloomFilterNoNotification(@Nullable byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_BLOOM_FILTER_NO_NOTIFICATION);
+    }
+
+    /**
+     * Get random resolvableData
+     */
+    @Nullable
+    public static byte[] getRandomResolvableData(byte[] serviceData) {
+        return getExtraField(serviceData, FIELD_TYPE_RANDOM_RESOLVABLE_DATA);
+    }
+
+    @Nullable
+    private static byte[] getExtraField(@Nullable byte[] serviceData, int fieldId) {
+        if (serviceData == null || serviceData.length < HEADER_INDEX + HEADER_LENGTH) {
+            return null;
+        }
+        try {
+            return getExtraFields(serviceData).get(fieldId);
+        } catch (IllegalFormatException e) {
+            return null;
+        }
+    }
+
+    /** Gets extra field data at the end of the packet, defined by the extra field header. */
+    private static SparseArray<byte[]> getExtraFields(byte[] serviceData)
+            throws IllegalFormatException {
+        SparseArray<byte[]> extraFields = new SparseArray<>();
+        if (getVersion(serviceData) != 0) {
+            return extraFields;
+        }
+        int headerIndex = getFirstExtraFieldHeaderIndex(serviceData);
+        while (headerIndex < serviceData.length) {
+            int length = getExtraFieldLength(serviceData, headerIndex);
+            int index = headerIndex + FIELD_HEADER_LENGTH;
+            int type = getExtraFieldType(serviceData, headerIndex);
+            int end = index + length;
+            if (extraFields.get(type) == null) {
+                if (end <= serviceData.length) {
+                    extraFields.put(type, Arrays.copyOfRange(serviceData, index, end));
+                } else {
+                    throw new IllegalFormatException(
+                            "Invalid length, " + end + " is longer than service data size "
+                                    + serviceData.length);
+                }
+            }
+            headerIndex = end;
+        }
+        return extraFields;
+    }
+
+    /** Checks whether or not a valid ID is included in the service data packet. */
+    public static boolean hasBeaconIdBytes(BleRecord bleRecord) {
+        byte[] serviceData = bleRecord.getServiceData(FAST_PAIR_SERVICE_PARCEL_UUID);
+        return checkModelId(serviceData);
+    }
+
+    /** Check whether byte array is FastPair model id or not. */
+    public static boolean checkModelId(@Nullable byte[] scanResult) {
+        return scanResult != null
+                // The 3-byte format has no header byte (all bytes are the ID).
+                && (scanResult.length == MIN_ID_LENGTH
+                // Header byte exists. We support only format version 0. (A different version
+                // indicates
+                // a breaking change in the format.)
+                || (scanResult.length > MIN_ID_LENGTH
+                && getVersion(scanResult) == 0
+                && isIdLengthValid(scanResult)));
+    }
+
+    /** Checks whether or not bloom filter is included in the service data packet. */
+    public static boolean hasBloomFilter(BleRecord bleRecord) {
+        return (getBloomFilter(getServiceDataArray(bleRecord)) != null
+                || getBloomFilterNoNotification(getServiceDataArray(bleRecord)) != null);
+    }
+
+    /** Checks whether or not bloom filter is included in the service data packet. */
+    public static boolean hasBloomFilter(ScanRecord scanRecord) {
+        return (getBloomFilter(getServiceDataArray(scanRecord)) != null
+                || getBloomFilterNoNotification(getServiceDataArray(scanRecord)) != null);
+    }
+
+    private static int getVersion(byte[] serviceData) {
+        return serviceData.length == MIN_ID_LENGTH
+                ? 0
+                : (serviceData[HEADER_INDEX] & HEADER_VERSION_BITMASK) >> HEADER_VERSION_OFFSET;
+    }
+
+    private static int getIdLength(byte[] serviceData) {
+        return serviceData.length == MIN_ID_LENGTH
+                ? MIN_ID_LENGTH
+                : (serviceData[HEADER_INDEX] & HEADER_LENGTH_BITMASK) >> HEADER_LENGTH_OFFSET;
+    }
+
+    private static int getFirstExtraFieldHeaderIndex(byte[] serviceData) {
+        return HEADER_INDEX + HEADER_LENGTH + getIdLength(serviceData);
+    }
+
+    private static int getExtraFieldLength(byte[] serviceData, int extraFieldIndex) {
+        return (serviceData[extraFieldIndex] & EXTRA_FIELD_LENGTH_BITMASK)
+                >> EXTRA_FIELD_LENGTH_OFFSET;
+    }
+
+    private static int getExtraFieldType(byte[] serviceData, int extraFieldIndex) {
+        return (serviceData[extraFieldIndex] & EXTRA_FIELD_TYPE_BITMASK) >> EXTRA_FIELD_TYPE_OFFSET;
+    }
+
+    private static boolean isIdLengthValid(byte[] serviceData) {
+        int idLength = getIdLength(serviceData);
+        return MIN_ID_LENGTH <= idLength
+                && idLength <= MAX_ID_LENGTH
+                && idLength + HEADER_LENGTH <= serviceData.length;
+    }
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/util/ForegroundThread.java b/nearby/service/java/com/android/server/nearby/util/ForegroundThread.java
new file mode 100644
index 0000000..793ab9a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/ForegroundThread.java
@@ -0,0 +1,113 @@
+/*
+ * 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 android.annotation.NonNull;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.Preconditions;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
+
+/**
+ * Shared singleton foreground thread.
+ */
+public class ForegroundThread extends HandlerThread {
+    private static final Object sLock = new Object();
+
+    @GuardedBy("sLock")
+    private static ForegroundThread sInstance;
+    @GuardedBy("sLock")
+    private static Handler sHandler;
+    @GuardedBy("sLock")
+    private static Executor sExecutor;
+
+    private ForegroundThread() {
+        super(ForegroundThread.class.getName());
+    }
+
+    @GuardedBy("sLock")
+    private static void ensureInstanceLocked() {
+        if (sInstance == null) {
+            sInstance = new ForegroundThread();
+            sInstance.start();
+            sHandler = new Handler(sInstance.getLooper());
+            sExecutor = new HandlerExecutor(sHandler);
+        }
+    }
+
+    /**
+     * Get the singleton instance of thi class.
+     *
+     * @return the singleton instance of thi class
+     */
+    @NonNull
+    public static ForegroundThread get() {
+        synchronized (sLock) {
+            ensureInstanceLocked();
+            return sInstance;
+        }
+    }
+
+    /**
+     * Get the {@link Handler} for this thread.
+     *
+     * @return the {@link Handler} for this thread.
+     */
+    @NonNull
+    public static Handler getHandler() {
+        synchronized (sLock) {
+            ensureInstanceLocked();
+            return sHandler;
+        }
+    }
+
+    /**
+     * Get the {@link Executor} for this thread.
+     *
+     * @return the {@link Executor} for this thread.
+     */
+    @NonNull
+    public static Executor getExecutor() {
+        synchronized (sLock) {
+            ensureInstanceLocked();
+            return sExecutor;
+        }
+    }
+
+    /**
+     * An adapter {@link Executor} that posts all executed tasks onto the given
+     * {@link Handler}.
+     */
+    private static class HandlerExecutor implements Executor {
+        private final Handler mHandler;
+
+        HandlerExecutor(@NonNull Handler handler) {
+            mHandler = Preconditions.checkNotNull(handler);
+        }
+
+        @Override
+        public void execute(Runnable command) {
+            if (!mHandler.post(command)) {
+                throw new RejectedExecutionException(mHandler + " is shutting down");
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/Hex.java b/nearby/service/java/com/android/server/nearby/util/Hex.java
new file mode 100644
index 0000000..1d1d855
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/Hex.java
@@ -0,0 +1,82 @@
+/*
+ * 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;
+
+/**
+ * Hex class that contains hex related functions.
+ */
+public class Hex {
+
+    private static final char[] HEX_UPPERCASE = {
+            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+    };
+
+    private static final char[] HEX_LOWERCASE = {
+            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+    };
+
+    /**
+     * Bytes array to lower case string.
+     */
+    public static String bytesToStringLowercase(byte[] bytes) {
+        char[] hexChars = new char[bytes.length * 2];
+        int j = 0;
+        for (byte aByte : bytes) {
+            int v = aByte & 0xFF;
+            hexChars[j++] = HEX_LOWERCASE[v >>> 4];
+            hexChars[j++] = HEX_LOWERCASE[v & 0x0F];
+        }
+        return new String(hexChars);
+    }
+
+    /**
+     * Encodes the byte array to string.
+     */
+    public static String bytesToStringUppercase(byte[] bytes) {
+        return bytesToStringUppercase(bytes, false /* zeroTerminated */);
+    }
+
+    /** Encodes a byte array as a hexadecimal representation of bytes. */
+    public static String bytesToStringUppercase(byte[] bytes, boolean zeroTerminated) {
+        int length = bytes.length;
+        StringBuilder out = new StringBuilder(length * 2);
+        for (int i = 0; i < length; i++) {
+            if (zeroTerminated && i == length - 1 && (bytes[i] & 0xff) == 0) {
+                break;
+            }
+            out.append(HEX_UPPERCASE[(bytes[i] & 0xf0) >>> 4]);
+            out.append(HEX_UPPERCASE[bytes[i] & 0x0f]);
+        }
+        return out.toString();
+    }
+    /**
+     * Converts string to byte array.
+     */
+    public static byte[] stringToBytes(String hex) throws IllegalArgumentException {
+        int length = hex.length();
+        if (length % 2 != 0) {
+            throw new IllegalArgumentException("Hex string has odd number of characters");
+        }
+        byte[] out = new byte[length / 2];
+        for (int i = 0; i < length; i += 2) {
+            // Byte.parseByte() doesn't work here because it expects a hex value in -128, 127, and
+            // our hex values are in 0, 255.
+            out[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16);
+        }
+        return out;
+    }
+}
diff --git a/nearby/service/proto/Android.bp b/nearby/service/proto/Android.bp
new file mode 100644
index 0000000..1b00cf6
--- /dev/null
+++ b/nearby/service/proto/Android.bp
@@ -0,0 +1,44 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+    name: "fast-pair-lite-protos",
+    proto: {
+        type: "lite",
+        canonical_path_from_root: false,
+    },
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+    srcs: ["src/fastpair/*.proto"],
+    apex_available: [
+        "com.android.tethering",
+    ],
+}
+
+java_library {
+    name: "presence-lite-protos",
+    proto: {
+        type: "lite",
+        canonical_path_from_root: false,
+    },
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+    srcs: ["src/presence/*.proto"],
+    apex_available: [
+        "com.android.tethering",
+    ],
+}
\ No newline at end of file
diff --git a/nearby/service/proto/src/fastpair/cache.proto b/nearby/service/proto/src/fastpair/cache.proto
new file mode 100644
index 0000000..12731fb
--- /dev/null
+++ b/nearby/service/proto/src/fastpair/cache.proto
@@ -0,0 +1,461 @@
+syntax = "proto3";
+package service.proto;
+import "src/fastpair/rpcs.proto";
+import "src/fastpair/fast_pair_string.proto";
+
+// db information for Fast Pair that gets from server.
+message ServerResponseDbItem {
+  // Device's model id.
+  string model_id = 1;
+
+  // Response was received from the server. Contains data needed to display
+  // FastPair notification such as device name, txPower of device, image used
+  // in the notification, etc.
+  GetObservedDeviceResponse get_observed_device_response = 2;
+
+  // The timestamp that make the server fetch.
+  int64 last_fetch_info_timestamp_millis = 3;
+
+  // Whether the item in the cache is expirable or not (when offline mode this
+  // will be false).
+  bool expirable = 4;
+}
+
+
+// Client side scan result.
+message StoredScanResult {
+  // REQUIRED
+  // Unique ID generated based on scan result
+  string id = 1;
+
+  // REQUIRED
+  NearbyType type = 2;
+
+  // REQUIRED
+  // The most recent all upper case mac associated with this item.
+  // (Mac-to-DiscoveryItem is a many-to-many relationship)
+  string mac_address = 4;
+
+  // Beacon's RSSI value
+  int32 rssi = 10;
+
+  // Beacon's tx power
+  int32 tx_power = 11;
+
+  // The mac address encoded in beacon advertisement. Currently only used by
+  // chromecast.
+  string device_setup_mac = 12;
+
+  // Uptime of the device in minutes. Stops incrementing at 255.
+  int32 uptime_minutes = 13;
+
+  // REQUIRED
+  // Client timestamp when the beacon was first observed in BLE scan.
+  int64 first_observation_timestamp_millis = 14;
+
+  // REQUIRED
+  // Client timestamp when the beacon was last observed in BLE scan.
+  int64 last_observation_timestamp_millis = 15;
+
+  // Deprecated fields.
+  reserved 3, 5, 6, 7, 8, 9;
+}
+
+
+// Data for a DiscoveryItem created from server response and client scan result.
+// Only caching original data from scan result, server response, timestamps
+// and user actions. Do not save generated data in this object.
+// Next ID: 50
+message StoredDiscoveryItem {
+  enum State {
+    // Default unknown state.
+    STATE_UNKNOWN = 0;
+
+    // The item is normal.
+    STATE_ENABLED = 1;
+
+    // The item has been muted by user.
+    STATE_MUTED = 2;
+
+    // The item has been disabled by us (likely temporarily).
+    STATE_DISABLED_BY_SYSTEM = 3;
+  }
+
+  // The status of the item.
+  // TODO(b/204409421) remove enum
+  enum DebugMessageCategory {
+    // Default unknown state.
+    STATUS_UNKNOWN = 0;
+
+    // The item is valid and visible in notification.
+    STATUS_VALID_NOTIFICATION = 1;
+
+    // The item made it to list but not to notification.
+    STATUS_VALID_LIST_VIEW = 2;
+
+    // The item is filtered out on client. Never made it to list view.
+    STATUS_DISABLED_BY_CLIENT = 3;
+
+    // The item is filtered out by server. Never made it to client.
+    STATUS_DISABLED_BY_SERVER = 4;
+  }
+
+  enum ExperienceType {
+    EXPERIENCE_UNKNOWN = 0;
+    EXPERIENCE_GOOD = 1;
+    EXPERIENCE_BAD = 2;
+  }
+
+  // REQUIRED
+  // Offline item: unique ID generated on client.
+  // Online item: unique ID generated on server.
+  string id = 1;
+
+  // REQUIRED
+  NearbyType type = 2;
+
+  // REQUIRED
+  // The most recent all upper case mac associated with this item.
+  // (Mac-to-DiscoveryItem is a many-to-many relationship)
+  string mac_address = 4;
+
+  // REQUIRED
+  string action_url = 5;
+
+  // The bluetooth device name from advertisment
+  string device_name = 6;
+
+  // REQUIRED
+  // Item's title
+  string title = 7;
+
+  // Item's description.
+  string description = 8;
+
+  // The URL for display
+  string display_url = 9;
+
+  // REQUIRED
+  // Client timestamp when the beacon was last observed in BLE scan.
+  int64 last_observation_timestamp_millis = 10;
+
+  // REQUIRED
+  // Client timestamp when the beacon was first observed in BLE scan.
+  int64 first_observation_timestamp_millis = 11;
+
+  // REQUIRED
+  // Item's current state. e.g. if the item is blocked.
+  State state = 17;
+
+  // The resolved url type for the action_url.
+  ResolvedUrlType action_url_type = 19;
+
+  // The timestamp when the user is redirected to Play Store after clicking on
+  // the item.
+  int64 pending_app_install_timestamp_millis = 20;
+
+  // Beacon's RSSI value
+  int32 rssi = 22;
+
+  // Beacon's tx power
+  int32 tx_power = 23;
+
+  // Human readable name of the app designated to open the uri
+  // Used in the second line of the notification, "Open in {} app"
+  string app_name = 25;
+
+  // ID used for associating several DiscoveryItems.  These items may be
+  // visually displayed together.
+  string group_id = 26;
+
+  // The timestamp when the attachment was created on PBS server. In case there
+  // are duplicate
+  // items with the same scanId/groupID, only show the one with the latest
+  // timestamp.
+  int64 attachment_creation_sec = 28;
+
+  // Whether the attachment is created in debug namespace
+  DiscoveryAttachmentType attachment_type = 29;
+
+  // Package name of the App that owns this item.
+  string package_name = 30;
+
+  // The average star rating of the app.
+  float star_rating = 31;
+
+  // The "feature" graphic image url used for large sized list view entries.
+  string feature_graphic_url = 32;
+
+  // TriggerId identifies the trigger/beacon that is attached with a message.
+  // It's generated from server for online messages to synchronize formatting
+  // across client versions.
+  // Example:
+  // * BLE_UID: 3||deadbeef
+  // * BLE_URL: http://trigger.id
+  // See go/discovery-store-message-and-trigger-id for more details.
+  string trigger_id = 34;
+
+  // Bytes of item icon in PNG format displayed in Discovery item list.
+  bytes icon_png = 36;
+
+  // A FIFE URL of the item icon displayed in Discovery item list.
+  string icon_fife_url = 49;
+
+  // Message written to bugreport for 3P developers.(No sensitive info)
+  // null if the item is valid
+  string debug_message = 37;
+
+  // Weather the item is filtered out on server.
+  DebugMessageCategory debug_category = 38;
+
+  // Client timestamp when the trigger (e.g. beacon) was last lost (e.g. when
+  // Messages told us the beacon's no longer nearby).
+  int64 lost_millis = 41;
+
+  // The kind of expereince the user last had with this (e.g. if they dismissed
+  // the notification, that's bad; but if they tapped it, that's good).
+  ExperienceType last_user_experience = 42;
+
+  // The most recent BLE advertisement related to this item.
+  bytes ble_record_bytes = 43;
+
+  // An ID generated on the server to uniquely identify content.
+  string entity_id = 44;
+
+  // See equivalent field in NearbyItem.
+  bytes authentication_public_key_secp256r1 = 45;
+
+  // See equivalent field in NearbyItem.
+  FastPairInformation fast_pair_information = 46;
+
+  // Companion app detail.
+  CompanionAppDetails companion_detail = 47;
+
+  // Fast pair strings
+  FastPairStrings fast_pair_strings = 48;
+
+  // Deprecated fields.
+  reserved 3, 12, 13, 14, 15, 16, 18, 21, 24, 27, 33, 35, 39, 40;
+}
+enum ResolvedUrlType {
+  RESOLVED_URL_TYPE_UNKNOWN = 0;
+
+  // The url is resolved to a web page that is not a play store app.
+  // This can be considered as the default resolved type when it's
+  // not the other specific types.
+  WEBPAGE = 1;
+
+  // The url is resolved to the Google Play store app
+  // ie. play.google.com/store
+  APP = 2;
+}
+enum DiscoveryAttachmentType {
+  DISCOVERY_ATTACHMENT_TYPE_UNKNOWN = 0;
+
+  // The attachment is posted in the prod namespace (without "-debug")
+  DISCOVERY_ATTACHMENT_TYPE_NORMAL = 1;
+
+  // The attachment is posted in the debug namespace (with "-debug")
+  DISCOVERY_ATTACHMENT_TYPE_DEBUG = 2;
+}
+// Additional information relevant only for Fast Pair devices.
+message FastPairInformation {
+  // When true, Fast Pair will only create a bond with the device and not
+  // attempt to connect any profiles (for example, A2DP or HFP).
+  bool data_only_connection = 1;
+
+  // Additional images that are attached specifically for true wireless Fast
+  // Pair devices.
+  TrueWirelessHeadsetImages true_wireless_images = 3;
+
+  // When true, this device can support assistant function.
+  bool assistant_supported = 4;
+
+  // Features supported by the Fast Pair device.
+  repeated FastPairFeature features = 5;
+
+  // Optional, the name of the company producing this Fast Pair device.
+  string company_name = 6;
+
+  // Optional, the type of device.
+  DeviceType device_type = 7;
+
+  reserved 2;
+}
+
+
+enum NearbyType {
+  NEARBY_TYPE_UNKNOWN = 0;
+  // Proximity Beacon Service (PBS). This is the only type of nearbyItems which
+  // can be customized by 3p and therefore the intents passed should not be
+  // completely trusted. Deprecated already.
+  NEARBY_PROXIMITY_BEACON = 1;
+  // Physical Web URL beacon. Deprecated already.
+  NEARBY_PHYSICAL_WEB = 2;
+  // Chromecast beacon. Used on client-side only.
+  NEARBY_CHROMECAST = 3;
+  // Wear beacon. Used on client-side only.
+  NEARBY_WEAR = 4;
+  // A device (e.g. a Magic Pair device that needs to be set up). The special-
+  // case devices above (e.g. ChromeCast, Wear) might migrate to this type.
+  NEARBY_DEVICE = 6;
+  // Popular apps/urls based on user's current geo-location.
+  NEARBY_POPULAR_HERE = 7;
+
+  reserved 5;
+}
+
+// A locally cached Fast Pair device associating an account key with the
+// bluetooth address of the device.
+message StoredFastPairItem {
+  // The device's public mac address.
+  string mac_address = 1;
+
+  // The account key written to the device.
+  bytes account_key = 2;
+
+  // When user need to update provider name, enable this value to trigger
+  // writing new name to provider.
+  bool need_to_update_provider_name = 3;
+
+  // The retry times to update name into provider.
+  int32 update_name_retries = 4;
+
+  // Latest firmware version from the server.
+  string latest_firmware_version = 5;
+
+  // The firmware version that is on the device.
+  string device_firmware_version = 6;
+
+  // The timestamp from the last time we fetched the firmware version from the
+  // device.
+  int64 last_check_firmware_timestamp_millis = 7;
+
+  // The timestamp from the last time we fetched the firmware version from
+  // server.
+  int64 last_server_query_timestamp_millis = 8;
+
+  // Only allows one bloom filter check process to create gatt connection and
+  // try to read the firmware version value.
+  bool can_read_firmware = 9;
+
+  // Device's model id.
+  string model_id = 10;
+
+  // Features that this Fast Pair device supports.
+  repeated FastPairFeature features = 11;
+
+  // Keeps the stored discovery item in local cache, we can have most
+  // information of fast pair device locally without through footprints, i.e. we
+  // can have most fast pair features locally.
+  StoredDiscoveryItem discovery_item = 12;
+
+  // When true, the latest uploaded event to FMA is connected. We use
+  // it as the previous ACL state when getting the BluetoothAdapter STATE_OFF to
+  // determine if need to upload the disconnect event to FMA.
+  bool fma_state_is_connected = 13;
+
+  // Device's buffer size range.
+  repeated BufferSizeRange buffer_size_range = 18;
+
+  // The additional account key if this device could be associated with multiple
+  // accounts. Notes that for this device, the account_key field is the basic
+  // one which will not be associated with the accounts.
+  repeated bytes additional_account_key = 19;
+
+  // Deprecated fields.
+  reserved 14, 15, 16, 17;
+}
+
+// Contains information about Fast Pair devices stored through our scanner.
+// Next ID: 29
+message ScanFastPairStoreItem {
+  // Device's model id.
+  string model_id = 1;
+
+  // Device's RSSI value
+  int32 rssi = 2;
+
+  // Device's tx power
+  int32 tx_power = 3;
+
+  // Bytes of item icon in PNG format displayed in Discovery item list.
+  bytes icon_png = 4;
+
+  // A FIFE URL of the item icon displayed in Discovery item list.
+  string icon_fife_url = 28;
+
+  // Device name like "Bose QC 35".
+  string device_name = 5;
+
+  // Client timestamp when user last saw Fast Pair device.
+  int64 last_observation_timestamp_millis = 6;
+
+  // Action url after user click the notification.
+  string action_url = 7;
+
+  // Device's bluetooth address.
+  string address = 8;
+
+  // The computed threshold rssi value that would trigger FastPair notifications
+  int32 threshold_rssi = 9;
+
+  // Populated with the contents of the bloom filter in the event that
+  // the scanned device is advertising a bloom filter instead of a model id
+  bytes bloom_filter = 10;
+
+  // Device name from the BLE scan record
+  string ble_device_name = 11;
+
+  // Strings used for the FastPair UI
+  FastPairStrings fast_pair_strings = 12;
+
+  // A key used to authenticate advertising device.
+  // See NearbyItem.authentication_public_key_secp256r1 for more information.
+  bytes anti_spoofing_public_key = 13;
+
+  // When true, Fast Pair will only create a bond with the device and not
+  // attempt to connect any profiles (for example, A2DP or HFP).
+  bool data_only_connection = 14;
+
+  // The type of the manufacturer (first party, third party, etc).
+  int32 manufacturer_type_num = 15;
+
+  // Additional images that are attached specifically for true wireless Fast
+  // Pair devices.
+  TrueWirelessHeadsetImages true_wireless_images = 16;
+
+  // When true, this device can support assistant function.
+  bool assistant_supported = 17;
+
+  // Optional, the name of the company producing this Fast Pair device.
+  string company_name = 18;
+
+  // Features supported by the Fast Pair device.
+  FastPairFeature features = 19;
+
+  // The interaction type that this scan should trigger
+  InteractionType interaction_type = 20;
+
+  // The copy of the advertisement bytes, used to pass along to other
+  // apps that use Fast Pair as the discovery vehicle.
+  bytes full_ble_record = 21;
+
+  // Companion app related information
+  CompanionAppDetails companion_detail = 22;
+
+  // Client timestamp when user first saw Fast Pair device.
+  int64 first_observation_timestamp_millis = 23;
+
+  // The type of the device (wearable, headphones, etc).
+  int32 device_type_num = 24;
+
+  // The type of notification (app launch smart setup, etc).
+  NotificationType notification_type = 25;
+
+  // The customized title.
+  string customized_title = 26;
+
+  // The customized description.
+  string customized_description = 27;
+}
diff --git a/nearby/service/proto/src/fastpair/data.proto b/nearby/service/proto/src/fastpair/data.proto
new file mode 100644
index 0000000..6f4fadd
--- /dev/null
+++ b/nearby/service/proto/src/fastpair/data.proto
@@ -0,0 +1,26 @@
+syntax = "proto3";
+
+package service.proto;
+import "src/fastpair/cache.proto";
+
+// A device that has been Fast Paired with.
+message FastPairDeviceWithAccountKey {
+  // The account key which was written to the device after pairing completed.
+  bytes account_key = 1;
+
+  // The stored discovery item which represents the notification that should be
+  // associated with the device. Note, this is stored as a raw byte array
+  // instead of StoredDiscoveryItem because icing only supports proto lite and
+  // StoredDiscoveryItem is handed around as a nano proto in implementation,
+  // which are not compatible with each other.
+  StoredDiscoveryItem discovery_item = 3;
+
+  // SHA256 of "account key + headset's public address", this is used to
+  // identify the paired headset. Because of adding account key to generate the
+  // hash value, it makes the information anonymous, even for the same headset,
+  // different accounts have different values.
+  bytes sha256_account_key_public_address = 4;
+
+  // Deprecated fields.
+  reserved 2;
+}
diff --git a/nearby/service/proto/src/fastpair/fast_pair_string.proto b/nearby/service/proto/src/fastpair/fast_pair_string.proto
new file mode 100644
index 0000000..6dfc19a
--- /dev/null
+++ b/nearby/service/proto/src/fastpair/fast_pair_string.proto
@@ -0,0 +1,65 @@
+syntax = "proto2";
+
+package service.proto;
+
+message FastPairStrings {
+  // Required for initial pairing, used when there is a Google account on the
+  // device
+  optional string tap_to_pair_with_account = 1;
+
+  // Required for initial pairing, used when there is no Google account on the
+  // device
+  optional string tap_to_pair_without_account = 2;
+
+  // Description for initial pairing
+  optional string initial_pairing_description = 3;
+
+  // Description after successfully paired the device with companion app
+  // installed
+  optional string pairing_finished_companion_app_installed = 4;
+
+  // Description after successfully paired the device with companion app not
+  // installed
+  optional string pairing_finished_companion_app_not_installed = 5;
+
+  // Description when phone found the device that associates with user's account
+  // before remind user to pair with new device.
+  optional string subsequent_pairing_description = 6;
+
+  // Description when fast pair finds the user paired with device manually
+  // reminds user to opt the device into cloud.
+  optional string retroactive_pairing_description = 7;
+
+  // Description when user click setup device while device is still pairing
+  optional string wait_app_launch_description = 8;
+
+  // Description when user fail to pair with device
+  optional string pairing_fail_description = 9;
+
+  // Title to ask the user to confirm the pin code.
+  optional string confirm_pin_title = 10;
+
+  // Description to ask the user to confirm the pin code.
+  optional string confirm_pin_description = 11;
+
+  // The title of the UI to ask the user to confirm to sync contacts.
+  optional string sync_contacts_title = 12;
+
+  // The description of the UI to ask the user to confirm to sync contacts.
+  optional string sync_contacts_description = 13;
+
+  // The title of the UI to ask the user to confirm to sync SMS.
+  optional string sync_sms_title = 14;
+
+  // The description of the UI to ask the user to confirm to sync SMS.
+  optional string sync_sms_description = 15;
+
+  // The description for half sheet to ask user to setup google assistant.
+  optional string assistant_half_sheet_description = 16;
+
+  // The description for notification to ask user to setup google assistant.
+  optional string assistant_notification_description = 17;
+
+  // Description of the connect device action on TV, when user is not logged in.
+  optional string fast_pair_tv_connect_device_no_account_description = 18;
+}
diff --git a/nearby/service/proto/src/fastpair/rpcs.proto b/nearby/service/proto/src/fastpair/rpcs.proto
new file mode 100644
index 0000000..0399d09
--- /dev/null
+++ b/nearby/service/proto/src/fastpair/rpcs.proto
@@ -0,0 +1,329 @@
+// RPCs for the Nearby Console service.
+syntax = "proto3";
+
+package service.proto;
+// Response containing an observed device.
+message GetObservedDeviceResponse {
+  // The device from the request.
+  Device device = 1;
+
+  // The image icon that shows in the notification
+  bytes image = 3;
+
+  // Strings to be displayed on notifications during the pairing process.
+  ObservedDeviceStrings strings = 4;
+
+  reserved 2;
+}
+
+message Device {
+  // Output only. The server-generated ID of the device.
+  int64 id = 1;
+
+  // The pantheon project number the device is created under. Only Nearby admins
+  // can change this.
+  int64 project_number = 2;
+
+  // How the notification will be displayed to the user
+  NotificationType notification_type = 3;
+
+  // The image to show on the notification.
+  string image_url = 4;
+
+  // The name of the device.
+  string name = 5;
+
+  // The intent that will be launched via the notification.
+  string intent_uri = 6;
+
+  // The transmit power of the device's BLE chip.
+  int32 ble_tx_power = 7;
+
+  // The distance that the device must be within to show a notification.
+  // If no distance is set, we default to 0.6 meters. Only Nearby admins can
+  // change this.
+  float trigger_distance = 8;
+
+  // Output only. Fast Pair only - The anti-spoofing key pair for the device.
+  AntiSpoofingKeyPair anti_spoofing_key_pair = 9;
+
+  // Output only. The current status of the device.
+  Status status = 10;
+
+
+  // DEPRECATED - check for published_version instead.
+  // Output only.
+  // Whether the device has a different, already published version.
+  bool has_published_version = 12;
+
+  // Fast Pair only - The type of device being registered.
+  DeviceType device_type = 13;
+
+
+  // Fast Pair only - Additional images for true wireless headsets.
+  TrueWirelessHeadsetImages true_wireless_images = 15;
+
+  // Fast Pair only - When true, this device can support assistant function.
+  bool assistant_supported = 16;
+
+  // Output only.
+  // The published version of a device that has been approved to be displayed
+  // as a notification - only populated if the device has a different published
+  // version. (A device that only has a published version would not have this
+  // populated).
+  Device published_version = 17;
+
+  // Fast Pair only - When true, Fast Pair will only create a bond with the
+  // device and not attempt to connect any profiles (for example, A2DP or HFP).
+  bool data_only_connection = 18;
+
+  // Name of the company/brand that will be selling the product.
+  string company_name = 19;
+
+  repeated FastPairFeature features = 20;
+
+  // Name of the device that is displayed on the console.
+  string display_name = 21;
+
+  // How the device will be interacted with by the user when the scan record
+  // is detected.
+  InteractionType interaction_type = 22;
+
+  // Companion app information.
+  CompanionAppDetails companion_detail = 23;
+
+  reserved 11, 14;
+}
+
+
+// Represents the format of the final device notification (which is directly
+// correlated to the action taken by the notification).
+enum NotificationType {
+  // Unspecified notification type.
+  NOTIFICATION_TYPE_UNSPECIFIED = 0;
+  // Notification launches the fast pair intent.
+  // Example Notification Title: "Bose SoundLink II"
+  // Notification Description: "Tap to pair with this device"
+  FAST_PAIR = 1;
+  // Notification launches an app.
+  // Notification Title: "[X]" where X is type/name of the device.
+  // Notification Description: "Tap to setup this device"
+  APP_LAUNCH = 2;
+  // Notification launches for Nearby Setup. The notification title and
+  // description is the same as APP_LAUNCH.
+  NEARBY_SETUP = 3;
+  // Notification launches the fast pair intent, but doesn't include an anti-
+  // spoofing key. The notification title and description is the same as
+  // FAST_PAIR.
+  FAST_PAIR_ONE = 4;
+  // Notification launches Smart Setup on devices.
+  // These notifications are identical to APP_LAUNCH except that they always
+  // launch Smart Setup intents within GMSCore.
+  SMART_SETUP = 5;
+}
+
+// How the device will be interacted with when it is seen.
+enum InteractionType {
+  INTERACTION_TYPE_UNKNOWN = 0;
+  AUTO_LAUNCH = 1;
+  NOTIFICATION = 2;
+}
+
+// Features that can be enabled for a Fast Pair device.
+enum FastPairFeature {
+  FAST_PAIR_FEATURE_UNKNOWN = 0;
+  SILENCE_MODE = 1;
+  WIRELESS_CHARGING = 2;
+  DYNAMIC_BUFFER_SIZE = 3;
+  NO_PERSONALIZED_NAME = 4;
+  EDDYSTONE_TRACKING = 5;
+}
+
+message CompanionAppDetails {
+  // Companion app slice provider's authority.
+  string authority = 1;
+
+  // Companion app certificate value.
+  string certificate_hash = 2;
+
+  // Deprecated fields.
+  reserved 3;
+}
+
+// Additional images for True Wireless Fast Pair devices.
+message TrueWirelessHeadsetImages {
+  // Image URL for the left bud.
+  string left_bud_url = 1;
+
+  // Image URL for the right bud.
+  string right_bud_url = 2;
+
+  // Image URL for the case.
+  string case_url = 3;
+}
+
+// Represents the type of device that is being registered.
+enum DeviceType {
+  DEVICE_TYPE_UNSPECIFIED = 0;
+  HEADPHONES = 1;
+  SPEAKER = 2;
+  WEARABLE = 3;
+  INPUT_DEVICE = 4;
+  AUTOMOTIVE = 5;
+  OTHER = 6;
+  TRUE_WIRELESS_HEADPHONES = 7;
+  WEAR_OS = 8;
+  ANDROID_AUTO = 9;
+}
+
+// An anti-spoofing key pair for a device that allows us to verify the device is
+// broadcasting legitimately.
+message AntiSpoofingKeyPair {
+  // The private key (restricted to only be viewable by trusted clients).
+  bytes private_key = 1;
+
+  // The public key.
+  bytes public_key = 2;
+}
+
+// Various states that a customer-configured device notification can be in.
+// PUBLISHED is the only state that shows notifications to the public.
+message Status {
+  // Status types available for each device.
+  enum StatusType {
+    // Unknown status.
+    TYPE_UNSPECIFIED = 0;
+    // Drafted device.
+    DRAFT = 1;
+    // Submitted and waiting for approval.
+    SUBMITTED = 2;
+    // Fully approved and available for end users.
+    PUBLISHED = 3;
+    // Rejected and not available for end users.
+    REJECTED = 4;
+  }
+
+  // Details about a device that has been rejected.
+  message RejectionDetails {
+    // The reason for the rejection.
+    enum RejectionReason {
+      // Unspecified reason.
+      REASON_UNSPECIFIED = 0;
+      // Name is not valid.
+      NAME = 1;
+      // Image is not valid.
+      IMAGE = 2;
+      // Tests have failed.
+      TESTS = 3;
+      // Other reason.
+      OTHER = 4;
+    }
+
+    // A list of reasons the device was rejected.
+    repeated RejectionReason reasons = 1;
+    // Comment about an OTHER rejection reason.
+    string additional_comment = 2;
+  }
+
+  // The status of the device.
+  StatusType status_type = 1;
+
+  // Accompanies Status.REJECTED.
+  RejectionDetails rejection_details = 2;
+}
+
+// Strings to be displayed in notifications surfaced for a device.
+message ObservedDeviceStrings {
+  // The locale of all of the strings.
+  string locale = 1;
+
+  // The notification description for when the device is initially discovered.
+  string initial_notification_description = 2;
+
+  // The notification description for when the device is initially discovered
+  // and no account is logged in.
+  string initial_notification_description_no_account = 3;
+
+  // The notification description for once we have finished pairing and the
+  // companion app has been opened. For google assistant devices, this string will point
+  // users to setting up the assistant.
+  string open_companion_app_description = 4;
+
+  // The notification description for once we have finished pairing and the
+  // companion app needs to be updated before use.
+  string update_companion_app_description = 5;
+
+  // The notification description for once we have finished pairing and the
+  // companion app needs to be installed.
+  string download_companion_app_description = 6;
+
+  // The notification title when a pairing fails.
+  string unable_to_connect_title = 7;
+
+  // The notification summary when a pairing fails.
+  string unable_to_connect_description = 8;
+
+  // The description that helps user initially paired with device.
+  string initial_pairing_description = 9;
+
+  // The description that let user open the companion app.
+  string connect_success_companion_app_installed = 10;
+
+  // The description that let user download the companion app.
+  string connect_success_companion_app_not_installed = 11;
+
+  // The description that reminds user there is a paired device nearby.
+  string subsequent_pairing_description = 12;
+
+  // The description that reminds users opt in their device.
+  string retroactive_pairing_description = 13;
+
+  // The description that indicates companion app is about to launch.
+  string wait_launch_companion_app_description = 14;
+
+  // The description that indicates go to bluetooth settings when connection
+  // fail.
+  string fail_connect_go_to_settings_description = 15;
+
+  // The title of the UI to ask the user to confirm the pin code.
+  string confirm_pin_title = 16;
+
+  // The description of the UI to ask the user to confirm the pin code.
+  string confirm_pin_description = 17;
+
+  // The title of the UI to ask the user to confirm to sync contacts.
+  string sync_contacts_title = 18;
+
+  // The description of the UI to ask the user to confirm to sync contacts.
+  string sync_contacts_description = 19;
+
+  // The title of the UI to ask the user to confirm to sync SMS.
+  string sync_sms_title = 20;
+
+  // The description of the UI to ask the user to confirm to sync SMS.
+  string sync_sms_description = 21;
+
+  // The description in half sheet to ask user setup google assistant
+  string assistant_setup_half_sheet = 22;
+
+  // The description in notification to ask user setup google assistant
+  string assistant_setup_notification = 23;
+
+  // Description of the connect device action on TV, when user is not logged in.
+  string fast_pair_tv_connect_device_no_account_description = 24;
+}
+
+// The buffer size range of a Fast Pair devices support dynamic buffer size.
+message BufferSizeRange {
+  // The max buffer size in ms.
+  int32 max_size = 1;
+
+  // The min buffer size in ms.
+  int32 min_size = 2;
+
+  // The default buffer size in ms.
+  int32 default_size = 3;
+
+  // The codec of this buffer size range.
+  int32 codec = 4;
+}
diff --git a/nearby/service/proto/src/presence/blefilter.proto b/nearby/service/proto/src/presence/blefilter.proto
new file mode 100644
index 0000000..9f75d34
--- /dev/null
+++ b/nearby/service/proto/src/presence/blefilter.proto
@@ -0,0 +1,93 @@
+/*
+ * 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.
+ */
+
+// Proto Messages define the interface between Nearby nanoapp and its host.
+//
+// Host registers its interest in BLE event by configuring nanoapp with Filters.
+// The nanoapp keeps watching BLE events and notifies host once an event matches
+// a Filter.
+//
+// Each Filter is defined by its id (required) with optional fields of rssi,
+// uuid, MAC etc. The host should guarantee the uniqueness of ids. It is
+// convenient to assign id incrementally when adding a Filter such that its id
+// is the same as the index of the repeated field in Filters.
+//
+// The nanoapp compares each BLE event against the list of Filters, and notifies
+// host when the event matches a Filter. The Field's id will be sent back to
+// host in the FilterResult.
+//
+// It is possible for the nanoapp to return multiple ids when an event matches
+// multiple Filters.
+
+syntax = "proto2";
+
+package service.proto;
+
+// Certificate to verify BLE events from trusted devices.
+// When receiving an advertisement from a remote device, it will
+// be decrypted by authenticity_key and SHA hashed. The device
+// is verified as trusted if the hash result is equal to
+// metadata_encryption_key_tag.
+// See details in go/ns-certificates.
+message PublicateCertificate {
+  optional bytes authenticity_key = 1;
+  optional bytes metadata_encryption_key_tag = 2;
+}
+
+message PublicCredential {
+  optional bytes secret_id = 1;
+  optional bytes authenticity_key = 2;
+  optional bytes public_key = 3;
+  optional bytes encrypted_metadata = 4;
+  optional bytes encrypted_metadata_tag = 5;
+}
+
+message BleFilter {
+  optional uint32 id = 1;  // Required, unique id of this filter.
+  // Maximum delay to notify the client after an event occurs.
+  optional uint32 latency_ms = 2;
+  optional uint32 uuid = 3;
+  // MAC address of the advertising device.
+  optional bytes mac_address = 4;
+  optional bytes mac_mask = 5;
+  // Represents an action that scanners should take when they receive this
+  // packet. See go/nearby-presence-spec for details.
+  optional uint32 intent = 6;
+  // Notify the client if the advertising device is within the distance.
+  // For moving object, the distance is averaged over data sampled within
+  // the period of latency defined above.
+  optional float distance_m = 7;
+  // Used to verify the list of trusted devices.
+  repeated PublicateCertificate certficate = 8;
+}
+
+message BleFilters {
+  repeated BleFilter filter = 1;
+}
+
+// FilterResult is returned to host when a BLE event matches a Filter.
+message BleFilterResult {
+  optional uint32 id = 1;  // id of the matched Filter.
+  optional uint32 tx_power = 2;
+  optional uint32 rssi = 3;
+  optional uint32 intent = 4;
+  optional bytes bluetooth_address = 5;
+  optional PublicCredential public_credential = 6;
+}
+
+message BleFilterResults {
+  repeated BleFilterResult result = 1;
+}
diff --git a/nearby/tests/cts/fastpair/Android.bp b/nearby/tests/cts/fastpair/Android.bp
new file mode 100644
index 0000000..845ed84
--- /dev/null
+++ b/nearby/tests/cts/fastpair/Android.bp
@@ -0,0 +1,47 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "CtsNearbyFastPairTestCases",
+    defaults: ["cts_defaults"],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "androidx.test.ext.truth",
+        "androidx.test.rules",
+        "bluetooth-test-util-lib",
+        "compatibility-device-util-axt",
+        "ctstestrunner-axt",
+        "truth-prebuilt",
+    ],
+    libs: [
+        "android.test.base",
+        "framework-bluetooth.stubs.module_lib",
+        "framework-connectivity-t.impl",
+    ],
+    srcs: ["src/**/*.java"],
+    test_suites: [
+        "cts",
+        "general-tests",
+        "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/AndroidManifest.xml b/nearby/tests/cts/fastpair/AndroidManifest.xml
new file mode 100644
index 0000000..ce841f2
--- /dev/null
+++ b/nearby/tests/cts/fastpair/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ * 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.nearby.cts">
+  <uses-sdk android:minSdkVersion="32" android:targetSdkVersion="32" />
+  <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+  <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
+
+  <application>
+    <uses-library android:name="android.test.runner"/>
+  </application>
+
+  <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+      android:targetPackage="android.nearby.cts"
+      android:label="CTS tests for android.nearby Fast Pair">
+    <meta-data android:name="listener"
+        android:value="com.android.cts.runner.CtsTestRunListener"/>
+  </instrumentation>
+</manifest>
diff --git a/nearby/tests/cts/fastpair/AndroidTest.xml b/nearby/tests/cts/fastpair/AndroidTest.xml
new file mode 100644
index 0000000..360bbf3
--- /dev/null
+++ b/nearby/tests/cts/fastpair/AndroidTest.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<configuration description="Config for CTS Nearby Fast Pair test cases">
+  <option name="test-suite-tag" value="cts" />
+  <option name="config-descriptor:metadata" key="component" value="location" />
+  <!-- Instant cannot access NearbyManager. -->
+  <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
+  <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
+  <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+  <option name="config-descriptor:metadata" key="parameter" value="all_foldable_states" />
+  <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+    <option name="cleanup-apks" value="true" />
+    <option name="test-file-name" value="CtsNearbyFastPairTestCases.apk" />
+  </target_preparer>
+  <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+    <option name="package" value="android.nearby.cts" />
+  </test>
+  <!-- Only run NearbyUnitTests in MTS if the Nearby Mainline module is installed. -->
+  <object type="module_controller"
+      class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+    <option name="mainline-module-package-name" value="com.google.android.tethering" />
+  </object>
+</configuration>
diff --git a/nearby/tests/cts/fastpair/OWNERS b/nearby/tests/cts/fastpair/OWNERS
new file mode 100644
index 0000000..1756bba
--- /dev/null
+++ b/nearby/tests/cts/fastpair/OWNERS
@@ -0,0 +1,4 @@
+# Bug component: 1092133
+
+chunzhang@google.com
+weiwa@google.com
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/CredentialElementTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/CredentialElementTest.java
new file mode 100644
index 0000000..aacb6d8
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/CredentialElementTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.CredentialElement;
+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.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class CredentialElementTest {
+    private static final String KEY = "SECRETE_ID";
+    private static final byte[] VALUE = new byte[]{1, 2, 3, 4};
+
+    @Test
+    @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();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWriteParcel() {
+        CredentialElement element = new CredentialElement(KEY, VALUE);
+
+        Parcel parcel = Parcel.obtain();
+        element.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        CredentialElement elementFromParcel = element.CREATOR.createFromParcel(
+                parcel);
+        parcel.recycle();
+
+        assertThat(elementFromParcel.getKey()).isEqualTo(KEY);
+        assertThat(Arrays.equals(elementFromParcel.getValue(), VALUE)).isTrue();
+    }
+
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/DataElementTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/DataElementTest.java
new file mode 100644
index 0000000..ec6e89a
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/DataElementTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.DataElement;
+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.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class DataElementTest {
+
+    private static final int KEY = 1234;
+    private static final byte[] VALUE = new byte[]{1, 1, 1, 1};
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBuilder() {
+        DataElement dataElement = new DataElement(KEY, VALUE);
+
+        assertThat(dataElement.getKey()).isEqualTo(KEY);
+        assertThat(Arrays.equals(dataElement.getValue(), VALUE)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWriteParcel() {
+        DataElement dataElement = new DataElement(KEY, VALUE);
+
+        Parcel parcel = Parcel.obtain();
+        dataElement.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        DataElement elementFromParcel = DataElement.CREATOR.createFromParcel(
+                parcel);
+        parcel.recycle();
+
+        assertThat(elementFromParcel.getKey()).isEqualTo(KEY);
+        assertThat(Arrays.equals(elementFromParcel.getValue(), VALUE)).isTrue();
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairAntispoofKeyDeviceMetadataTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairAntispoofKeyDeviceMetadataTest.java
new file mode 100644
index 0000000..226efbb
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairAntispoofKeyDeviceMetadataTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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 android.nearby.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.FastPairAntispoofKeyDeviceMetadata;
+import android.nearby.FastPairDeviceMetadata;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class FastPairAntispoofKeyDeviceMetadataTest {
+
+    private static final String ASSISTANT_SETUP_HALFSHEET = "ASSISTANT_SETUP_HALFSHEET";
+    private static final String ASSISTANT_SETUP_NOTIFICATION = "ASSISTANT_SETUP_NOTIFICATION";
+    private static final int BLE_TX_POWER  = 5;
+    private static final String CONFIRM_PIN_DESCRIPTION = "CONFIRM_PIN_DESCRIPTION";
+    private static final String CONFIRM_PIN_TITLE = "CONFIRM_PIN_TITLE";
+    private static final String CONNECT_SUCCESS_COMPANION_APP_INSTALLED =
+            "CONNECT_SUCCESS_COMPANION_APP_INSTALLED";
+    private static final String CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED =
+            "CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED";
+    private static final float DELTA = 0.001f;
+    private static final int DEVICE_TYPE = 7;
+    private static final String DOWNLOAD_COMPANION_APP_DESCRIPTION =
+            "DOWNLOAD_COMPANION_APP_DESCRIPTION";
+    private static final String FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION =
+            "FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION";
+    private static final String FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION =
+            "FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION";
+    private static final byte[] IMAGE = new byte[] {7, 9};
+    private static final String IMAGE_URL = "IMAGE_URL";
+    private static final String INITIAL_NOTIFICATION_DESCRIPTION =
+            "INITIAL_NOTIFICATION_DESCRIPTION";
+    private static final String INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT =
+            "INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT";
+    private static final String INITIAL_PAIRING_DESCRIPTION = "INITIAL_PAIRING_DESCRIPTION";
+    private static final String INTENT_URI = "INTENT_URI";
+    private static final String LOCALE = "LOCALE";
+    private static final String OPEN_COMPANION_APP_DESCRIPTION = "OPEN_COMPANION_APP_DESCRIPTION";
+    private static final String RETRO_ACTIVE_PAIRING_DESCRIPTION =
+            "RETRO_ACTIVE_PAIRING_DESCRIPTION";
+    private static final String SUBSEQUENT_PAIRING_DESCRIPTION = "SUBSEQUENT_PAIRING_DESCRIPTION";
+    private static final String SYNC_CONTACT_DESCRPTION = "SYNC_CONTACT_DESCRPTION";
+    private static final String SYNC_CONTACTS_TITLE = "SYNC_CONTACTS_TITLE";
+    private static final String SYNC_SMS_DESCRIPTION = "SYNC_SMS_DESCRIPTION";
+    private static final String SYNC_SMS_TITLE = "SYNC_SMS_TITLE";
+    private static final float TRIGGER_DISTANCE = 111;
+    private static final String TRUE_WIRELESS_IMAGE_URL_CASE = "TRUE_WIRELESS_IMAGE_URL_CASE";
+    private static final String TRUE_WIRELESS_IMAGE_URL_LEFT_BUD =
+            "TRUE_WIRELESS_IMAGE_URL_LEFT_BUD";
+    private static final String TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD =
+            "TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD";
+    private static final String UNABLE_TO_CONNECT_DESCRIPTION = "UNABLE_TO_CONNECT_DESCRIPTION";
+    private static final String UNABLE_TO_CONNECT_TITLE = "UNABLE_TO_CONNECT_TITLE";
+    private static final String UPDATE_COMPANION_APP_DESCRIPTION =
+            "UPDATE_COMPANION_APP_DESCRIPTION";
+    private static final String WAIT_LAUNCH_COMPANION_APP_DESCRIPTION =
+            "WAIT_LAUNCH_COMPANION_APP_DESCRIPTION";
+    private static final byte[] ANTI_SPOOFING_KEY = new byte[] {4, 5, 6};
+    private static final String NAME = "NAME";
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetGetFastPairAntispoofKeyDeviceMetadataNotNull() {
+        FastPairDeviceMetadata fastPairDeviceMetadata = genFastPairDeviceMetadata();
+        FastPairAntispoofKeyDeviceMetadata fastPairAntispoofKeyDeviceMetadata =
+                genFastPairAntispoofKeyDeviceMetadata(ANTI_SPOOFING_KEY, fastPairDeviceMetadata);
+
+        assertThat(fastPairAntispoofKeyDeviceMetadata.getAntispoofPublicKey()).isEqualTo(
+                ANTI_SPOOFING_KEY);
+        ensureFastPairDeviceMetadataAsExpected(
+                fastPairAntispoofKeyDeviceMetadata.getFastPairDeviceMetadata());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetGetFastPairAntispoofKeyDeviceMetadataNull() {
+        FastPairAntispoofKeyDeviceMetadata fastPairAntispoofKeyDeviceMetadata =
+                genFastPairAntispoofKeyDeviceMetadata(null, null);
+        assertThat(fastPairAntispoofKeyDeviceMetadata.getAntispoofPublicKey()).isEqualTo(
+                null);
+        assertThat(fastPairAntispoofKeyDeviceMetadata.getFastPairDeviceMetadata()).isEqualTo(
+                null);
+    }
+
+    /* Verifies DeviceMetadata. */
+    private static void ensureFastPairDeviceMetadataAsExpected(FastPairDeviceMetadata metadata) {
+        assertThat(metadata.getAssistantSetupHalfSheet()).isEqualTo(ASSISTANT_SETUP_HALFSHEET);
+        assertThat(metadata.getAssistantSetupNotification())
+                .isEqualTo(ASSISTANT_SETUP_NOTIFICATION);
+        assertThat(metadata.getBleTxPower()).isEqualTo(BLE_TX_POWER);
+        assertThat(metadata.getConfirmPinDescription()).isEqualTo(CONFIRM_PIN_DESCRIPTION);
+        assertThat(metadata.getConfirmPinTitle()).isEqualTo(CONFIRM_PIN_TITLE);
+        assertThat(metadata.getConnectSuccessCompanionAppInstalled())
+                .isEqualTo(CONNECT_SUCCESS_COMPANION_APP_INSTALLED);
+        assertThat(metadata.getConnectSuccessCompanionAppNotInstalled())
+                .isEqualTo(CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED);
+        assertThat(metadata.getDeviceType()).isEqualTo(DEVICE_TYPE);
+        assertThat(metadata.getDownloadCompanionAppDescription())
+                .isEqualTo(DOWNLOAD_COMPANION_APP_DESCRIPTION);
+        assertThat(metadata.getFailConnectGoToSettingsDescription())
+                .isEqualTo(FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION);
+        assertThat(metadata.getFastPairTvConnectDeviceNoAccountDescription())
+                .isEqualTo(FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+        assertThat(metadata.getImage()).isEqualTo(IMAGE);
+        assertThat(metadata.getImageUrl()).isEqualTo(IMAGE_URL);
+        assertThat(metadata.getInitialNotificationDescription())
+                .isEqualTo(INITIAL_NOTIFICATION_DESCRIPTION);
+        assertThat(metadata.getInitialNotificationDescriptionNoAccount())
+                .isEqualTo(INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT);
+        assertThat(metadata.getInitialPairingDescription()).isEqualTo(INITIAL_PAIRING_DESCRIPTION);
+        assertThat(metadata.getIntentUri()).isEqualTo(INTENT_URI);
+        assertThat(metadata.getLocale()).isEqualTo(LOCALE);
+        assertThat(metadata.getName()).isEqualTo(NAME);
+        assertThat(metadata.getOpenCompanionAppDescription())
+                .isEqualTo(OPEN_COMPANION_APP_DESCRIPTION);
+        assertThat(metadata.getRetroactivePairingDescription())
+                .isEqualTo(RETRO_ACTIVE_PAIRING_DESCRIPTION);
+        assertThat(metadata.getSubsequentPairingDescription())
+                .isEqualTo(SUBSEQUENT_PAIRING_DESCRIPTION);
+        assertThat(metadata.getSyncContactsDescription()).isEqualTo(SYNC_CONTACT_DESCRPTION);
+        assertThat(metadata.getSyncContactsTitle()).isEqualTo(SYNC_CONTACTS_TITLE);
+        assertThat(metadata.getSyncSmsDescription()).isEqualTo(SYNC_SMS_DESCRIPTION);
+        assertThat(metadata.getSyncSmsTitle()).isEqualTo(SYNC_SMS_TITLE);
+        assertThat(metadata.getTriggerDistance()).isWithin(DELTA).of(TRIGGER_DISTANCE);
+        assertThat(metadata.getTrueWirelessImageUrlCase()).isEqualTo(TRUE_WIRELESS_IMAGE_URL_CASE);
+        assertThat(metadata.getTrueWirelessImageUrlLeftBud())
+                .isEqualTo(TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        assertThat(metadata.getTrueWirelessImageUrlRightBud())
+                .isEqualTo(TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+        assertThat(metadata.getUnableToConnectDescription())
+                .isEqualTo(UNABLE_TO_CONNECT_DESCRIPTION);
+        assertThat(metadata.getUnableToConnectTitle()).isEqualTo(UNABLE_TO_CONNECT_TITLE);
+        assertThat(metadata.getUpdateCompanionAppDescription())
+                .isEqualTo(UPDATE_COMPANION_APP_DESCRIPTION);
+        assertThat(metadata.getWaitLaunchCompanionAppDescription())
+                .isEqualTo(WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+    }
+
+    /* Generates FastPairAntispoofKeyDeviceMetadata. */
+    private static FastPairAntispoofKeyDeviceMetadata genFastPairAntispoofKeyDeviceMetadata(
+            byte[] antispoofPublicKey, FastPairDeviceMetadata deviceMetadata) {
+        FastPairAntispoofKeyDeviceMetadata.Builder builder =
+                new FastPairAntispoofKeyDeviceMetadata.Builder();
+        builder.setAntispoofPublicKey(antispoofPublicKey);
+        builder.setFastPairDeviceMetadata(deviceMetadata);
+
+        return builder.build();
+    }
+
+    /* Generates FastPairDeviceMetadata. */
+    private static FastPairDeviceMetadata genFastPairDeviceMetadata() {
+        FastPairDeviceMetadata.Builder builder = new FastPairDeviceMetadata.Builder();
+        builder.setAssistantSetupHalfSheet(ASSISTANT_SETUP_HALFSHEET);
+        builder.setAssistantSetupNotification(ASSISTANT_SETUP_NOTIFICATION);
+        builder.setBleTxPower(BLE_TX_POWER);
+        builder.setConfirmPinDescription(CONFIRM_PIN_DESCRIPTION);
+        builder.setConfirmPinTitle(CONFIRM_PIN_TITLE);
+        builder.setConnectSuccessCompanionAppInstalled(CONNECT_SUCCESS_COMPANION_APP_INSTALLED);
+        builder.setConnectSuccessCompanionAppNotInstalled(
+                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED);
+        builder.setDeviceType(DEVICE_TYPE);
+        builder.setDownloadCompanionAppDescription(DOWNLOAD_COMPANION_APP_DESCRIPTION);
+        builder.setFailConnectGoToSettingsDescription(FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION);
+        builder.setFastPairTvConnectDeviceNoAccountDescription(
+                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+        builder.setImage(IMAGE);
+        builder.setImageUrl(IMAGE_URL);
+        builder.setInitialNotificationDescription(INITIAL_NOTIFICATION_DESCRIPTION);
+        builder.setInitialNotificationDescriptionNoAccount(
+                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT);
+        builder.setInitialPairingDescription(INITIAL_PAIRING_DESCRIPTION);
+        builder.setIntentUri(INTENT_URI);
+        builder.setLocale(LOCALE);
+        builder.setName(NAME);
+        builder.setOpenCompanionAppDescription(OPEN_COMPANION_APP_DESCRIPTION);
+        builder.setRetroactivePairingDescription(RETRO_ACTIVE_PAIRING_DESCRIPTION);
+        builder.setSubsequentPairingDescription(SUBSEQUENT_PAIRING_DESCRIPTION);
+        builder.setSyncContactsDescription(SYNC_CONTACT_DESCRPTION);
+        builder.setSyncContactsTitle(SYNC_CONTACTS_TITLE);
+        builder.setSyncSmsDescription(SYNC_SMS_DESCRIPTION);
+        builder.setSyncSmsTitle(SYNC_SMS_TITLE);
+        builder.setTriggerDistance(TRIGGER_DISTANCE);
+        builder.setTrueWirelessImageUrlCase(TRUE_WIRELESS_IMAGE_URL_CASE);
+        builder.setTrueWirelessImageUrlLeftBud(TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        builder.setTrueWirelessImageUrlRightBud(TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+        builder.setUnableToConnectDescription(UNABLE_TO_CONNECT_DESCRIPTION);
+        builder.setUnableToConnectTitle(UNABLE_TO_CONNECT_TITLE);
+        builder.setUpdateCompanionAppDescription(UPDATE_COMPANION_APP_DESCRIPTION);
+        builder.setWaitLaunchCompanionAppDescription(WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+
+        return builder.build();
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairDataProviderServiceTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairDataProviderServiceTest.java
new file mode 100644
index 0000000..171b6e8
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairDataProviderServiceTest.java
@@ -0,0 +1,1084 @@
+/*
+ * 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 android.nearby.cts;
+
+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.MockitoAnnotations.initMocks;
+
+import android.accounts.Account;
+import android.content.Intent;
+import android.nearby.FastPairAccountKeyDeviceMetadata;
+import android.nearby.FastPairAntispoofKeyDeviceMetadata;
+import android.nearby.FastPairDataProviderService;
+import android.nearby.FastPairDeviceMetadata;
+import android.nearby.FastPairDiscoveryItem;
+import android.nearby.FastPairEligibleAccount;
+import android.nearby.aidl.ByteArrayParcel;
+import android.nearby.aidl.FastPairAccountDevicesMetadataRequestParcel;
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataRequestParcel;
+import android.nearby.aidl.FastPairDeviceMetadataParcel;
+import android.nearby.aidl.FastPairDiscoveryItemParcel;
+import android.nearby.aidl.FastPairEligibleAccountParcel;
+import android.nearby.aidl.FastPairEligibleAccountsRequestParcel;
+import android.nearby.aidl.FastPairManageAccountDeviceRequestParcel;
+import android.nearby.aidl.FastPairManageAccountRequestParcel;
+import android.nearby.aidl.IFastPairAccountDevicesMetadataCallback;
+import android.nearby.aidl.IFastPairAntispoofKeyDeviceMetadataCallback;
+import android.nearby.aidl.IFastPairDataProvider;
+import android.nearby.aidl.IFastPairEligibleAccountsCallback;
+import android.nearby.aidl.IFastPairManageAccountCallback;
+import android.nearby.aidl.IFastPairManageAccountDeviceCallback;
+
+import androidx.annotation.NonNull;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+
+@RunWith(AndroidJUnit4.class)
+public class FastPairDataProviderServiceTest {
+
+    private static final String TAG = "FastPairDataProviderServiceTest";
+
+    private static final String ASSISTANT_SETUP_HALFSHEET = "ASSISTANT_SETUP_HALFSHEET";
+    private static final String ASSISTANT_SETUP_NOTIFICATION = "ASSISTANT_SETUP_NOTIFICATION";
+    private static final int BLE_TX_POWER  = 5;
+    private static final String CONFIRM_PIN_DESCRIPTION = "CONFIRM_PIN_DESCRIPTION";
+    private static final String CONFIRM_PIN_TITLE = "CONFIRM_PIN_TITLE";
+    private static final String CONNECT_SUCCESS_COMPANION_APP_INSTALLED =
+            "CONNECT_SUCCESS_COMPANION_APP_INSTALLED";
+    private static final String CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED =
+            "CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED";
+    private static final float DELTA = 0.001f;
+    private static final int DEVICE_TYPE = 7;
+    private static final String DOWNLOAD_COMPANION_APP_DESCRIPTION =
+            "DOWNLOAD_COMPANION_APP_DESCRIPTION";
+    private static final Account ELIGIBLE_ACCOUNT_1 = new Account("abc@google.com", "type1");
+    private static final boolean ELIGIBLE_ACCOUNT_1_OPT_IN = true;
+    private static final Account ELIGIBLE_ACCOUNT_2 = new Account("def@gmail.com", "type2");
+    private static final boolean ELIGIBLE_ACCOUNT_2_OPT_IN = false;
+    private static final Account MANAGE_ACCOUNT = new Account("ghi@gmail.com", "type3");
+    private static final Account ACCOUNTDEVICES_METADATA_ACCOUNT =
+            new Account("jk@gmail.com", "type4");
+    private static final int NUM_ACCOUNT_DEVICES = 2;
+
+    private static final int ERROR_CODE_BAD_REQUEST =
+            FastPairDataProviderService.ERROR_CODE_BAD_REQUEST;
+    private static final int MANAGE_ACCOUNT_REQUEST_TYPE =
+            FastPairDataProviderService.MANAGE_REQUEST_ADD;
+    private static final String ERROR_STRING = "ERROR_STRING";
+    private static final String FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION =
+            "FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION";
+    private static final String FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION =
+            "FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION";
+    private static final byte[] IMAGE = new byte[] {7, 9};
+    private static final String IMAGE_URL = "IMAGE_URL";
+    private static final String INITIAL_NOTIFICATION_DESCRIPTION =
+            "INITIAL_NOTIFICATION_DESCRIPTION";
+    private static final String INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT =
+            "INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT";
+    private static final String INITIAL_PAIRING_DESCRIPTION = "INITIAL_PAIRING_DESCRIPTION";
+    private static final String INTENT_URI = "INTENT_URI";
+    private static final String LOCALE = "LOCALE";
+    private static final String OPEN_COMPANION_APP_DESCRIPTION = "OPEN_COMPANION_APP_DESCRIPTION";
+    private static final String RETRO_ACTIVE_PAIRING_DESCRIPTION =
+            "RETRO_ACTIVE_PAIRING_DESCRIPTION";
+    private static final String SUBSEQUENT_PAIRING_DESCRIPTION = "SUBSEQUENT_PAIRING_DESCRIPTION";
+    private static final String SYNC_CONTACT_DESCRPTION = "SYNC_CONTACT_DESCRPTION";
+    private static final String SYNC_CONTACTS_TITLE = "SYNC_CONTACTS_TITLE";
+    private static final String SYNC_SMS_DESCRIPTION = "SYNC_SMS_DESCRIPTION";
+    private static final String SYNC_SMS_TITLE = "SYNC_SMS_TITLE";
+    private static final float TRIGGER_DISTANCE = 111;
+    private static final String TRUE_WIRELESS_IMAGE_URL_CASE = "TRUE_WIRELESS_IMAGE_URL_CASE";
+    private static final String TRUE_WIRELESS_IMAGE_URL_LEFT_BUD =
+            "TRUE_WIRELESS_IMAGE_URL_LEFT_BUD";
+    private static final String TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD =
+            "TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD";
+    private static final String UNABLE_TO_CONNECT_DESCRIPTION = "UNABLE_TO_CONNECT_DESCRIPTION";
+    private static final String UNABLE_TO_CONNECT_TITLE = "UNABLE_TO_CONNECT_TITLE";
+    private static final String UPDATE_COMPANION_APP_DESCRIPTION =
+            "UPDATE_COMPANION_APP_DESCRIPTION";
+    private static final String WAIT_LAUNCH_COMPANION_APP_DESCRIPTION =
+            "WAIT_LAUNCH_COMPANION_APP_DESCRIPTION";
+    private static final byte[] ACCOUNT_KEY = new byte[] {3};
+    private static final byte[] ACCOUNT_KEY_2 = new byte[] {9, 3};
+    private static final byte[] SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS = new byte[] {2, 8};
+    private static final byte[] REQUEST_MODEL_ID = new byte[] {1, 2, 3};
+    private static final byte[] ANTI_SPOOFING_KEY = new byte[] {4, 5, 6};
+    private static final String ACTION_URL = "ACTION_URL";
+    private static final int ACTION_URL_TYPE = 5;
+    private static final String APP_NAME = "APP_NAME";
+    private static final int ATTACHMENT_TYPE = 8;
+    private static final byte[] AUTHENTICATION_PUBLIC_KEY_SEC_P256R1 = new byte[] {5, 7};
+    private static final byte[] BLE_RECORD_BYTES = new byte[]{2, 4};
+    private static final int DEBUG_CATEGORY = 9;
+    private static final String DEBUG_MESSAGE = "DEBUG_MESSAGE";
+    private static final String DESCRIPTION = "DESCRIPTION";
+    private static final String DEVICE_NAME = "DEVICE_NAME";
+    private static final String DISPLAY_URL = "DISPLAY_URL";
+    private static final String ENTITY_ID = "ENTITY_ID";
+    private static final String FEATURE_GRAPHIC_URL = "FEATURE_GRAPHIC_URL";
+    private static final long FIRST_OBSERVATION_TIMESTAMP_MILLIS = 8393L;
+    private static final String GROUP_ID = "GROUP_ID";
+    private static final String  ICON_FIFE_URL = "ICON_FIFE_URL";
+    private static final byte[]  ICON_PNG = new byte[]{2, 5};
+    private static final String ID = "ID";
+    private static final long LAST_OBSERVATION_TIMESTAMP_MILLIS = 934234L;
+    private static final int LAST_USER_EXPERIENCE = 93;
+    private static final long LOST_MILLIS = 393284L;
+    private static final String MAC_ADDRESS = "MAC_ADDRESS";
+    private static final String NAME = "NAME";
+    private static final String PACKAGE_NAME = "PACKAGE_NAME";
+    private static final long PENDING_APP_INSTALL_TIMESTAMP_MILLIS = 832393L;
+    private static final int RSSI = 9;
+    private static final int STATE = 63;
+    private static final String TITLE = "TITLE";
+    private static final String TRIGGER_ID = "TRIGGER_ID";
+    private static final int TX_POWER = 62;
+    private static final int TYPE = 73;
+    private static final String BLE_ADDRESS = "BLE_ADDRESS";
+
+    private static final int ELIGIBLE_ACCOUNTS_NUM = 2;
+    private static final ImmutableList<FastPairEligibleAccount> ELIGIBLE_ACCOUNTS =
+            ImmutableList.of(
+                    genHappyPathFastPairEligibleAccount(ELIGIBLE_ACCOUNT_1,
+                            ELIGIBLE_ACCOUNT_1_OPT_IN),
+                    genHappyPathFastPairEligibleAccount(ELIGIBLE_ACCOUNT_2,
+                            ELIGIBLE_ACCOUNT_2_OPT_IN));
+    private static final int ACCOUNTKEY_DEVICE_NUM = 2;
+    private static final ImmutableList<FastPairAccountKeyDeviceMetadata>
+            FAST_PAIR_ACCOUNT_DEVICES_METADATA =
+            ImmutableList.of(
+                    genHappyPathFastPairAccountkeyDeviceMetadata(),
+                    genHappyPathFastPairAccountkeyDeviceMetadata());
+
+    private static final FastPairAntispoofKeyDeviceMetadataRequestParcel
+            FAST_PAIR_ANTI_SPOOF_KEY_DEVICE_METADATA_REQUEST_PARCEL =
+            genFastPairAntispoofKeyDeviceMetadataRequestParcel();
+    private static final FastPairAccountDevicesMetadataRequestParcel
+            FAST_PAIR_ACCOUNT_DEVICES_METADATA_REQUEST_PARCEL =
+            genFastPairAccountDevicesMetadataRequestParcel();
+    private static final FastPairEligibleAccountsRequestParcel
+            FAST_PAIR_ELIGIBLE_ACCOUNTS_REQUEST_PARCEL =
+            genFastPairEligibleAccountsRequestParcel();
+    private static final FastPairManageAccountRequestParcel
+            FAST_PAIR_MANAGE_ACCOUNT_REQUEST_PARCEL =
+            genFastPairManageAccountRequestParcel();
+    private static final FastPairManageAccountDeviceRequestParcel
+            FAST_PAIR_MANAGE_ACCOUNT_DEVICE_REQUEST_PARCEL =
+            genFastPairManageAccountDeviceRequestParcel();
+    private static final FastPairAntispoofKeyDeviceMetadata
+            HAPPY_PATH_FAST_PAIR_ANTI_SPOOF_KEY_DEVICE_METADATA =
+            genHappyPathFastPairAntispoofKeyDeviceMetadata();
+
+    @Captor private ArgumentCaptor<FastPairEligibleAccountParcel[]>
+            mFastPairEligibleAccountParcelsArgumentCaptor;
+    @Captor private ArgumentCaptor<FastPairAccountKeyDeviceMetadataParcel[]>
+            mFastPairAccountKeyDeviceMetadataParcelsArgumentCaptor;
+
+    @Mock private FastPairDataProviderService mMockFastPairDataProviderService;
+    @Mock private IFastPairAntispoofKeyDeviceMetadataCallback.Stub
+            mAntispoofKeyDeviceMetadataCallback;
+    @Mock private IFastPairAccountDevicesMetadataCallback.Stub mAccountDevicesMetadataCallback;
+    @Mock private IFastPairEligibleAccountsCallback.Stub mEligibleAccountsCallback;
+    @Mock private IFastPairManageAccountCallback.Stub mManageAccountCallback;
+    @Mock private IFastPairManageAccountDeviceCallback.Stub mManageAccountDeviceCallback;
+
+    private MyHappyPathProvider mHappyPathFastPairDataProvider;
+    private MyErrorPathProvider mErrorPathFastPairDataProvider;
+
+    @Before
+    public void setUp() throws Exception {
+        initMocks(this);
+
+        mHappyPathFastPairDataProvider =
+                new MyHappyPathProvider(TAG, mMockFastPairDataProviderService);
+        mErrorPathFastPairDataProvider =
+                new MyErrorPathProvider(TAG, mMockFastPairDataProviderService);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathLoadFastPairAntispoofKeyDeviceMetadata() throws Exception {
+        // AOSP sends calls to OEM via Parcelable.
+        mHappyPathFastPairDataProvider.asProvider().loadFastPairAntispoofKeyDeviceMetadata(
+                FAST_PAIR_ANTI_SPOOF_KEY_DEVICE_METADATA_REQUEST_PARCEL,
+                mAntispoofKeyDeviceMetadataCallback);
+
+        // OEM receives request and verifies that it is as expected.
+        final ArgumentCaptor<FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataRequest>
+                fastPairAntispoofKeyDeviceMetadataRequestCaptor =
+                ArgumentCaptor.forClass(
+                        FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataRequest.class
+                );
+        verify(mMockFastPairDataProviderService).onLoadFastPairAntispoofKeyDeviceMetadata(
+                fastPairAntispoofKeyDeviceMetadataRequestCaptor.capture(),
+                any(FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataCallback.class));
+        ensureHappyPathAsExpected(fastPairAntispoofKeyDeviceMetadataRequestCaptor.getValue());
+
+        // AOSP receives responses and verifies that it is as expected.
+        final ArgumentCaptor<FastPairAntispoofKeyDeviceMetadataParcel>
+                fastPairAntispoofKeyDeviceMetadataParcelCaptor =
+                ArgumentCaptor.forClass(FastPairAntispoofKeyDeviceMetadataParcel.class);
+        verify(mAntispoofKeyDeviceMetadataCallback).onFastPairAntispoofKeyDeviceMetadataReceived(
+                fastPairAntispoofKeyDeviceMetadataParcelCaptor.capture());
+        ensureHappyPathAsExpected(fastPairAntispoofKeyDeviceMetadataParcelCaptor.getValue());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathLoadFastPairAccountDevicesMetadata() throws Exception {
+        // AOSP sends calls to OEM via Parcelable.
+        mHappyPathFastPairDataProvider.asProvider().loadFastPairAccountDevicesMetadata(
+                FAST_PAIR_ACCOUNT_DEVICES_METADATA_REQUEST_PARCEL,
+                mAccountDevicesMetadataCallback);
+
+        // OEM receives request and verifies that it is as expected.
+        final ArgumentCaptor<FastPairDataProviderService.FastPairAccountDevicesMetadataRequest>
+                fastPairAccountDevicesMetadataRequestCaptor =
+                ArgumentCaptor.forClass(
+                        FastPairDataProviderService.FastPairAccountDevicesMetadataRequest.class);
+        verify(mMockFastPairDataProviderService).onLoadFastPairAccountDevicesMetadata(
+                fastPairAccountDevicesMetadataRequestCaptor.capture(),
+                any(FastPairDataProviderService.FastPairAccountDevicesMetadataCallback.class));
+        ensureHappyPathAsExpected(fastPairAccountDevicesMetadataRequestCaptor.getValue());
+
+        // AOSP receives responses and verifies that it is as expected.
+        verify(mAccountDevicesMetadataCallback).onFastPairAccountDevicesMetadataReceived(
+                mFastPairAccountKeyDeviceMetadataParcelsArgumentCaptor.capture());
+        ensureHappyPathAsExpected(
+                mFastPairAccountKeyDeviceMetadataParcelsArgumentCaptor.getValue());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathLoadFastPairEligibleAccounts() throws Exception {
+        // AOSP sends calls to OEM via Parcelable.
+        mHappyPathFastPairDataProvider.asProvider().loadFastPairEligibleAccounts(
+                FAST_PAIR_ELIGIBLE_ACCOUNTS_REQUEST_PARCEL,
+                mEligibleAccountsCallback);
+
+        // OEM receives request and verifies that it is as expected.
+        final ArgumentCaptor<FastPairDataProviderService.FastPairEligibleAccountsRequest>
+                fastPairEligibleAccountsRequestCaptor =
+                ArgumentCaptor.forClass(
+                        FastPairDataProviderService.FastPairEligibleAccountsRequest.class);
+        verify(mMockFastPairDataProviderService).onLoadFastPairEligibleAccounts(
+                fastPairEligibleAccountsRequestCaptor.capture(),
+                any(FastPairDataProviderService.FastPairEligibleAccountsCallback.class));
+        ensureHappyPathAsExpected(fastPairEligibleAccountsRequestCaptor.getValue());
+
+        // AOSP receives responses and verifies that it is as expected.
+        verify(mEligibleAccountsCallback).onFastPairEligibleAccountsReceived(
+                mFastPairEligibleAccountParcelsArgumentCaptor.capture());
+        ensureHappyPathAsExpected(mFastPairEligibleAccountParcelsArgumentCaptor.getValue());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathManageFastPairAccount() throws Exception {
+        // AOSP sends calls to OEM via Parcelable.
+        mHappyPathFastPairDataProvider.asProvider().manageFastPairAccount(
+                FAST_PAIR_MANAGE_ACCOUNT_REQUEST_PARCEL,
+                mManageAccountCallback);
+
+        // OEM receives request and verifies that it is as expected.
+        final ArgumentCaptor<FastPairDataProviderService.FastPairManageAccountRequest>
+                fastPairManageAccountRequestCaptor =
+                ArgumentCaptor.forClass(
+                        FastPairDataProviderService.FastPairManageAccountRequest.class);
+        verify(mMockFastPairDataProviderService).onManageFastPairAccount(
+                fastPairManageAccountRequestCaptor.capture(),
+                any(FastPairDataProviderService.FastPairManageActionCallback.class));
+        ensureHappyPathAsExpected(fastPairManageAccountRequestCaptor.getValue());
+
+        // AOSP receives SUCCESS response.
+        verify(mManageAccountCallback).onSuccess();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathManageFastPairAccountDevice() throws Exception {
+        // AOSP sends calls to OEM via Parcelable.
+        mHappyPathFastPairDataProvider.asProvider().manageFastPairAccountDevice(
+                FAST_PAIR_MANAGE_ACCOUNT_DEVICE_REQUEST_PARCEL,
+                mManageAccountDeviceCallback);
+
+        // OEM receives request and verifies that it is as expected.
+        final ArgumentCaptor<FastPairDataProviderService.FastPairManageAccountDeviceRequest>
+                fastPairManageAccountDeviceRequestCaptor =
+                ArgumentCaptor.forClass(
+                        FastPairDataProviderService.FastPairManageAccountDeviceRequest.class);
+        verify(mMockFastPairDataProviderService).onManageFastPairAccountDevice(
+                fastPairManageAccountDeviceRequestCaptor.capture(),
+                any(FastPairDataProviderService.FastPairManageActionCallback.class));
+        ensureHappyPathAsExpected(fastPairManageAccountDeviceRequestCaptor.getValue());
+
+        // AOSP receives SUCCESS response.
+        verify(mManageAccountDeviceCallback).onSuccess();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testErrorPathLoadFastPairAntispoofKeyDeviceMetadata() throws Exception {
+        mErrorPathFastPairDataProvider.asProvider().loadFastPairAntispoofKeyDeviceMetadata(
+                FAST_PAIR_ANTI_SPOOF_KEY_DEVICE_METADATA_REQUEST_PARCEL,
+                mAntispoofKeyDeviceMetadataCallback);
+        verify(mMockFastPairDataProviderService).onLoadFastPairAntispoofKeyDeviceMetadata(
+                any(FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataRequest.class),
+                any(FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataCallback.class));
+        verify(mAntispoofKeyDeviceMetadataCallback).onError(
+                eq(ERROR_CODE_BAD_REQUEST), eq(ERROR_STRING));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testErrorPathLoadFastPairAccountDevicesMetadata() throws Exception {
+        mErrorPathFastPairDataProvider.asProvider().loadFastPairAccountDevicesMetadata(
+                FAST_PAIR_ACCOUNT_DEVICES_METADATA_REQUEST_PARCEL,
+                mAccountDevicesMetadataCallback);
+        verify(mMockFastPairDataProviderService).onLoadFastPairAccountDevicesMetadata(
+                any(FastPairDataProviderService.FastPairAccountDevicesMetadataRequest.class),
+                any(FastPairDataProviderService.FastPairAccountDevicesMetadataCallback.class));
+        verify(mAccountDevicesMetadataCallback).onError(
+                eq(ERROR_CODE_BAD_REQUEST), eq(ERROR_STRING));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testErrorPathLoadFastPairEligibleAccounts() throws Exception {
+        mErrorPathFastPairDataProvider.asProvider().loadFastPairEligibleAccounts(
+                FAST_PAIR_ELIGIBLE_ACCOUNTS_REQUEST_PARCEL,
+                mEligibleAccountsCallback);
+        verify(mMockFastPairDataProviderService).onLoadFastPairEligibleAccounts(
+                any(FastPairDataProviderService.FastPairEligibleAccountsRequest.class),
+                any(FastPairDataProviderService.FastPairEligibleAccountsCallback.class));
+        verify(mEligibleAccountsCallback).onError(
+                eq(ERROR_CODE_BAD_REQUEST), eq(ERROR_STRING));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testErrorPathManageFastPairAccount() throws Exception {
+        mErrorPathFastPairDataProvider.asProvider().manageFastPairAccount(
+                FAST_PAIR_MANAGE_ACCOUNT_REQUEST_PARCEL,
+                mManageAccountCallback);
+        verify(mMockFastPairDataProviderService).onManageFastPairAccount(
+                any(FastPairDataProviderService.FastPairManageAccountRequest.class),
+                any(FastPairDataProviderService.FastPairManageActionCallback.class));
+        verify(mManageAccountCallback).onError(eq(ERROR_CODE_BAD_REQUEST), eq(ERROR_STRING));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testErrorPathManageFastPairAccountDevice() throws Exception {
+        mErrorPathFastPairDataProvider.asProvider().manageFastPairAccountDevice(
+                FAST_PAIR_MANAGE_ACCOUNT_DEVICE_REQUEST_PARCEL,
+                mManageAccountDeviceCallback);
+        verify(mMockFastPairDataProviderService).onManageFastPairAccountDevice(
+                any(FastPairDataProviderService.FastPairManageAccountDeviceRequest.class),
+                any(FastPairDataProviderService.FastPairManageActionCallback.class));
+        verify(mManageAccountDeviceCallback).onError(eq(ERROR_CODE_BAD_REQUEST), eq(ERROR_STRING));
+    }
+
+    public static class MyHappyPathProvider extends FastPairDataProviderService {
+
+        private final FastPairDataProviderService mMockFastPairDataProviderService;
+
+        public MyHappyPathProvider(@NonNull String tag, FastPairDataProviderService mock) {
+            super(tag);
+            mMockFastPairDataProviderService = mock;
+        }
+
+        public IFastPairDataProvider asProvider() {
+            Intent intent = new Intent();
+            return IFastPairDataProvider.Stub.asInterface(onBind(intent));
+        }
+
+        @Override
+        public void onLoadFastPairAntispoofKeyDeviceMetadata(
+                @NonNull FastPairAntispoofKeyDeviceMetadataRequest request,
+                @NonNull FastPairAntispoofKeyDeviceMetadataCallback callback) {
+            mMockFastPairDataProviderService.onLoadFastPairAntispoofKeyDeviceMetadata(
+                    request, callback);
+            callback.onFastPairAntispoofKeyDeviceMetadataReceived(
+                    HAPPY_PATH_FAST_PAIR_ANTI_SPOOF_KEY_DEVICE_METADATA);
+        }
+
+        @Override
+        public void onLoadFastPairAccountDevicesMetadata(
+                @NonNull FastPairAccountDevicesMetadataRequest request,
+                @NonNull FastPairAccountDevicesMetadataCallback callback) {
+            mMockFastPairDataProviderService.onLoadFastPairAccountDevicesMetadata(
+                    request, callback);
+            callback.onFastPairAccountDevicesMetadataReceived(FAST_PAIR_ACCOUNT_DEVICES_METADATA);
+        }
+
+        @Override
+        public void onLoadFastPairEligibleAccounts(
+                @NonNull FastPairEligibleAccountsRequest request,
+                @NonNull FastPairEligibleAccountsCallback callback) {
+            mMockFastPairDataProviderService.onLoadFastPairEligibleAccounts(
+                    request, callback);
+            callback.onFastPairEligibleAccountsReceived(ELIGIBLE_ACCOUNTS);
+        }
+
+        @Override
+        public void onManageFastPairAccount(
+                @NonNull FastPairManageAccountRequest request,
+                @NonNull FastPairManageActionCallback callback) {
+            mMockFastPairDataProviderService.onManageFastPairAccount(request, callback);
+            callback.onSuccess();
+        }
+
+        @Override
+        public void onManageFastPairAccountDevice(
+                @NonNull FastPairManageAccountDeviceRequest request,
+                @NonNull FastPairManageActionCallback callback) {
+            mMockFastPairDataProviderService.onManageFastPairAccountDevice(request, callback);
+            callback.onSuccess();
+        }
+    }
+
+    public static class MyErrorPathProvider extends FastPairDataProviderService {
+
+        private final FastPairDataProviderService mMockFastPairDataProviderService;
+
+        public MyErrorPathProvider(@NonNull String tag, FastPairDataProviderService mock) {
+            super(tag);
+            mMockFastPairDataProviderService = mock;
+        }
+
+        public IFastPairDataProvider asProvider() {
+            Intent intent = new Intent();
+            return IFastPairDataProvider.Stub.asInterface(onBind(intent));
+        }
+
+        @Override
+        public void onLoadFastPairAntispoofKeyDeviceMetadata(
+                @NonNull FastPairAntispoofKeyDeviceMetadataRequest request,
+                @NonNull FastPairAntispoofKeyDeviceMetadataCallback callback) {
+            mMockFastPairDataProviderService.onLoadFastPairAntispoofKeyDeviceMetadata(
+                    request, callback);
+            callback.onError(ERROR_CODE_BAD_REQUEST, ERROR_STRING);
+        }
+
+        @Override
+        public void onLoadFastPairAccountDevicesMetadata(
+                @NonNull FastPairAccountDevicesMetadataRequest request,
+                @NonNull FastPairAccountDevicesMetadataCallback callback) {
+            mMockFastPairDataProviderService.onLoadFastPairAccountDevicesMetadata(
+                    request, callback);
+            callback.onError(ERROR_CODE_BAD_REQUEST, ERROR_STRING);
+        }
+
+        @Override
+        public void onLoadFastPairEligibleAccounts(
+                @NonNull FastPairEligibleAccountsRequest request,
+                @NonNull FastPairEligibleAccountsCallback callback) {
+            mMockFastPairDataProviderService.onLoadFastPairEligibleAccounts(request, callback);
+            callback.onError(ERROR_CODE_BAD_REQUEST, ERROR_STRING);
+        }
+
+        @Override
+        public void onManageFastPairAccount(
+                @NonNull FastPairManageAccountRequest request,
+                @NonNull FastPairManageActionCallback callback) {
+            mMockFastPairDataProviderService.onManageFastPairAccount(request, callback);
+            callback.onError(ERROR_CODE_BAD_REQUEST, ERROR_STRING);
+        }
+
+        @Override
+        public void onManageFastPairAccountDevice(
+                @NonNull FastPairManageAccountDeviceRequest request,
+                @NonNull FastPairManageActionCallback callback) {
+            mMockFastPairDataProviderService.onManageFastPairAccountDevice(request, callback);
+            callback.onError(ERROR_CODE_BAD_REQUEST, ERROR_STRING);
+        }
+    }
+
+    /* Generates AntispoofKeyDeviceMetadataRequestParcel. */
+    private static FastPairAntispoofKeyDeviceMetadataRequestParcel
+            genFastPairAntispoofKeyDeviceMetadataRequestParcel() {
+        FastPairAntispoofKeyDeviceMetadataRequestParcel requestParcel =
+                new FastPairAntispoofKeyDeviceMetadataRequestParcel();
+        requestParcel.modelId = REQUEST_MODEL_ID;
+
+        return requestParcel;
+    }
+
+    /* Generates AccountDevicesMetadataRequestParcel. */
+    private static FastPairAccountDevicesMetadataRequestParcel
+            genFastPairAccountDevicesMetadataRequestParcel() {
+        FastPairAccountDevicesMetadataRequestParcel requestParcel =
+                new FastPairAccountDevicesMetadataRequestParcel();
+
+        requestParcel.account = ACCOUNTDEVICES_METADATA_ACCOUNT;
+        requestParcel.deviceAccountKeys = new ByteArrayParcel[NUM_ACCOUNT_DEVICES];
+        requestParcel.deviceAccountKeys[0] = new ByteArrayParcel();
+        requestParcel.deviceAccountKeys[1] = new ByteArrayParcel();
+        requestParcel.deviceAccountKeys[0].byteArray = ACCOUNT_KEY;
+        requestParcel.deviceAccountKeys[1].byteArray = ACCOUNT_KEY_2;
+
+        return requestParcel;
+    }
+
+    /* Generates FastPairEligibleAccountsRequestParcel. */
+    private static FastPairEligibleAccountsRequestParcel
+            genFastPairEligibleAccountsRequestParcel() {
+        FastPairEligibleAccountsRequestParcel requestParcel =
+                new FastPairEligibleAccountsRequestParcel();
+        // No fields since FastPairEligibleAccountsRequestParcel is just a place holder now.
+        return requestParcel;
+    }
+
+    /* Generates FastPairManageAccountRequestParcel. */
+    private static FastPairManageAccountRequestParcel
+            genFastPairManageAccountRequestParcel() {
+        FastPairManageAccountRequestParcel requestParcel =
+                new FastPairManageAccountRequestParcel();
+        requestParcel.account = MANAGE_ACCOUNT;
+        requestParcel.requestType = MANAGE_ACCOUNT_REQUEST_TYPE;
+
+        return requestParcel;
+    }
+
+    /* Generates FastPairManageAccountDeviceRequestParcel. */
+    private static FastPairManageAccountDeviceRequestParcel
+            genFastPairManageAccountDeviceRequestParcel() {
+        FastPairManageAccountDeviceRequestParcel requestParcel =
+                new FastPairManageAccountDeviceRequestParcel();
+        requestParcel.account = MANAGE_ACCOUNT;
+        requestParcel.requestType = MANAGE_ACCOUNT_REQUEST_TYPE;
+        requestParcel.bleAddress = BLE_ADDRESS;
+        requestParcel.accountKeyDeviceMetadata =
+                genHappyPathFastPairAccountkeyDeviceMetadataParcel();
+
+        return requestParcel;
+    }
+
+    /* Generates Happy Path AntispoofKeyDeviceMetadata. */
+    private static FastPairAntispoofKeyDeviceMetadata
+            genHappyPathFastPairAntispoofKeyDeviceMetadata() {
+        FastPairAntispoofKeyDeviceMetadata.Builder builder =
+                new FastPairAntispoofKeyDeviceMetadata.Builder();
+        builder.setAntispoofPublicKey(ANTI_SPOOFING_KEY);
+        builder.setFastPairDeviceMetadata(genHappyPathFastPairDeviceMetadata());
+
+        return builder.build();
+    }
+
+    /* Generates Happy Path FastPairAccountKeyDeviceMetadata. */
+    private static FastPairAccountKeyDeviceMetadata
+            genHappyPathFastPairAccountkeyDeviceMetadata() {
+        FastPairAccountKeyDeviceMetadata.Builder builder =
+                new FastPairAccountKeyDeviceMetadata.Builder();
+        builder.setDeviceAccountKey(ACCOUNT_KEY);
+        builder.setFastPairDeviceMetadata(genHappyPathFastPairDeviceMetadata());
+        builder.setSha256DeviceAccountKeyPublicAddress(SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS);
+        builder.setFastPairDiscoveryItem(genHappyPathFastPairDiscoveryItem());
+
+        return builder.build();
+    }
+
+    /* Generates Happy Path FastPairAccountKeyDeviceMetadataParcel. */
+    private static FastPairAccountKeyDeviceMetadataParcel
+            genHappyPathFastPairAccountkeyDeviceMetadataParcel() {
+        FastPairAccountKeyDeviceMetadataParcel parcel =
+                new FastPairAccountKeyDeviceMetadataParcel();
+        parcel.deviceAccountKey = ACCOUNT_KEY;
+        parcel.metadata = genHappyPathFastPairDeviceMetadataParcel();
+        parcel.sha256DeviceAccountKeyPublicAddress = SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS;
+        parcel.discoveryItem = genHappyPathFastPairDiscoveryItemParcel();
+
+        return parcel;
+    }
+
+    /* Generates Happy Path DiscoveryItem. */
+    private static FastPairDiscoveryItem genHappyPathFastPairDiscoveryItem() {
+        FastPairDiscoveryItem.Builder builder = new FastPairDiscoveryItem.Builder();
+
+        builder.setActionUrl(ACTION_URL);
+        builder.setActionUrlType(ACTION_URL_TYPE);
+        builder.setAppName(APP_NAME);
+        builder.setAttachmentType(ATTACHMENT_TYPE);
+        builder.setAuthenticationPublicKeySecp256r1(AUTHENTICATION_PUBLIC_KEY_SEC_P256R1);
+        builder.setBleRecordBytes(BLE_RECORD_BYTES);
+        builder.setDebugCategory(DEBUG_CATEGORY);
+        builder.setDebugMessage(DEBUG_MESSAGE);
+        builder.setDescription(DESCRIPTION);
+        builder.setDeviceName(DEVICE_NAME);
+        builder.setDisplayUrl(DISPLAY_URL);
+        builder.setEntityId(ENTITY_ID);
+        builder.setFeatureGraphicUrl(FEATURE_GRAPHIC_URL);
+        builder.setFirstObservationTimestampMillis(FIRST_OBSERVATION_TIMESTAMP_MILLIS);
+        builder.setGroupId(GROUP_ID);
+        builder.setIconFfeUrl(ICON_FIFE_URL);
+        builder.setIconPng(ICON_PNG);
+        builder.setId(ID);
+        builder.setLastObservationTimestampMillis(LAST_OBSERVATION_TIMESTAMP_MILLIS);
+        builder.setLastUserExperience(LAST_USER_EXPERIENCE);
+        builder.setLostMillis(LOST_MILLIS);
+        builder.setMacAddress(MAC_ADDRESS);
+        builder.setPackageName(PACKAGE_NAME);
+        builder.setPendingAppInstallTimestampMillis(PENDING_APP_INSTALL_TIMESTAMP_MILLIS);
+        builder.setRssi(RSSI);
+        builder.setState(STATE);
+        builder.setTitle(TITLE);
+        builder.setTriggerId(TRIGGER_ID);
+        builder.setTxPower(TX_POWER);
+        builder.setType(TYPE);
+
+        return builder.build();
+    }
+
+    /* Generates Happy Path DiscoveryItemParcel. */
+    private static FastPairDiscoveryItemParcel genHappyPathFastPairDiscoveryItemParcel() {
+        FastPairDiscoveryItemParcel parcel = new FastPairDiscoveryItemParcel();
+
+        parcel.actionUrl = ACTION_URL;
+        parcel.actionUrlType = ACTION_URL_TYPE;
+        parcel.appName = APP_NAME;
+        parcel.attachmentType = ATTACHMENT_TYPE;
+        parcel.authenticationPublicKeySecp256r1 = AUTHENTICATION_PUBLIC_KEY_SEC_P256R1;
+        parcel.bleRecordBytes = BLE_RECORD_BYTES;
+        parcel.debugCategory = DEBUG_CATEGORY;
+        parcel.debugMessage = DEBUG_MESSAGE;
+        parcel.description = DESCRIPTION;
+        parcel.deviceName = DEVICE_NAME;
+        parcel.displayUrl = DISPLAY_URL;
+        parcel.entityId = ENTITY_ID;
+        parcel.featureGraphicUrl = FEATURE_GRAPHIC_URL;
+        parcel.firstObservationTimestampMillis = FIRST_OBSERVATION_TIMESTAMP_MILLIS;
+        parcel.groupId = GROUP_ID;
+        parcel.iconFifeUrl = ICON_FIFE_URL;
+        parcel.iconPng = ICON_PNG;
+        parcel.id = ID;
+        parcel.lastObservationTimestampMillis = LAST_OBSERVATION_TIMESTAMP_MILLIS;
+        parcel.lastUserExperience = LAST_USER_EXPERIENCE;
+        parcel.lostMillis = LOST_MILLIS;
+        parcel.macAddress = MAC_ADDRESS;
+        parcel.packageName = PACKAGE_NAME;
+        parcel.pendingAppInstallTimestampMillis = PENDING_APP_INSTALL_TIMESTAMP_MILLIS;
+        parcel.rssi = RSSI;
+        parcel.state = STATE;
+        parcel.title = TITLE;
+        parcel.triggerId = TRIGGER_ID;
+        parcel.txPower = TX_POWER;
+        parcel.type = TYPE;
+
+        return parcel;
+    }
+
+    /* Generates Happy Path DeviceMetadata. */
+    private static FastPairDeviceMetadata genHappyPathFastPairDeviceMetadata() {
+        FastPairDeviceMetadata.Builder builder = new FastPairDeviceMetadata.Builder();
+        builder.setAssistantSetupHalfSheet(ASSISTANT_SETUP_HALFSHEET);
+        builder.setAssistantSetupNotification(ASSISTANT_SETUP_NOTIFICATION);
+        builder.setBleTxPower(BLE_TX_POWER);
+        builder.setConfirmPinDescription(CONFIRM_PIN_DESCRIPTION);
+        builder.setConfirmPinTitle(CONFIRM_PIN_TITLE);
+        builder.setConnectSuccessCompanionAppInstalled(CONNECT_SUCCESS_COMPANION_APP_INSTALLED);
+        builder.setConnectSuccessCompanionAppNotInstalled(
+                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED);
+        builder.setDeviceType(DEVICE_TYPE);
+        builder.setDownloadCompanionAppDescription(DOWNLOAD_COMPANION_APP_DESCRIPTION);
+        builder.setFailConnectGoToSettingsDescription(FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION);
+        builder.setFastPairTvConnectDeviceNoAccountDescription(
+                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+        builder.setImage(IMAGE);
+        builder.setImageUrl(IMAGE_URL);
+        builder.setInitialNotificationDescription(INITIAL_NOTIFICATION_DESCRIPTION);
+        builder.setInitialNotificationDescriptionNoAccount(
+                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT);
+        builder.setInitialPairingDescription(INITIAL_PAIRING_DESCRIPTION);
+        builder.setIntentUri(INTENT_URI);
+        builder.setLocale(LOCALE);
+        builder.setName(NAME);
+        builder.setOpenCompanionAppDescription(OPEN_COMPANION_APP_DESCRIPTION);
+        builder.setRetroactivePairingDescription(RETRO_ACTIVE_PAIRING_DESCRIPTION);
+        builder.setSubsequentPairingDescription(SUBSEQUENT_PAIRING_DESCRIPTION);
+        builder.setSyncContactsDescription(SYNC_CONTACT_DESCRPTION);
+        builder.setSyncContactsTitle(SYNC_CONTACTS_TITLE);
+        builder.setSyncSmsDescription(SYNC_SMS_DESCRIPTION);
+        builder.setSyncSmsTitle(SYNC_SMS_TITLE);
+        builder.setTriggerDistance(TRIGGER_DISTANCE);
+        builder.setTrueWirelessImageUrlCase(TRUE_WIRELESS_IMAGE_URL_CASE);
+        builder.setTrueWirelessImageUrlLeftBud(TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        builder.setTrueWirelessImageUrlRightBud(TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+        builder.setUnableToConnectDescription(UNABLE_TO_CONNECT_DESCRIPTION);
+        builder.setUnableToConnectTitle(UNABLE_TO_CONNECT_TITLE);
+        builder.setUpdateCompanionAppDescription(UPDATE_COMPANION_APP_DESCRIPTION);
+        builder.setWaitLaunchCompanionAppDescription(WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+
+        return builder.build();
+    }
+
+    /* Generates Happy Path DeviceMetadataParcel. */
+    private static FastPairDeviceMetadataParcel genHappyPathFastPairDeviceMetadataParcel() {
+        FastPairDeviceMetadataParcel parcel = new FastPairDeviceMetadataParcel();
+
+        parcel.assistantSetupHalfSheet = ASSISTANT_SETUP_HALFSHEET;
+        parcel.assistantSetupNotification = ASSISTANT_SETUP_NOTIFICATION;
+        parcel.bleTxPower = BLE_TX_POWER;
+        parcel.confirmPinDescription = CONFIRM_PIN_DESCRIPTION;
+        parcel.confirmPinTitle = CONFIRM_PIN_TITLE;
+        parcel.connectSuccessCompanionAppInstalled = CONNECT_SUCCESS_COMPANION_APP_INSTALLED;
+        parcel.connectSuccessCompanionAppNotInstalled =
+                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED;
+        parcel.deviceType = DEVICE_TYPE;
+        parcel.downloadCompanionAppDescription = DOWNLOAD_COMPANION_APP_DESCRIPTION;
+        parcel.failConnectGoToSettingsDescription = FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION;
+        parcel.fastPairTvConnectDeviceNoAccountDescription =
+                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION;
+        parcel.image = IMAGE;
+        parcel.imageUrl = IMAGE_URL;
+        parcel.initialNotificationDescription = INITIAL_NOTIFICATION_DESCRIPTION;
+        parcel.initialNotificationDescriptionNoAccount =
+                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT;
+        parcel.initialPairingDescription = INITIAL_PAIRING_DESCRIPTION;
+        parcel.intentUri = INTENT_URI;
+        parcel.locale = LOCALE;
+        parcel.name = NAME;
+        parcel.openCompanionAppDescription = OPEN_COMPANION_APP_DESCRIPTION;
+        parcel.retroactivePairingDescription = RETRO_ACTIVE_PAIRING_DESCRIPTION;
+        parcel.subsequentPairingDescription = SUBSEQUENT_PAIRING_DESCRIPTION;
+        parcel.syncContactsDescription = SYNC_CONTACT_DESCRPTION;
+        parcel.syncContactsTitle = SYNC_CONTACTS_TITLE;
+        parcel.syncSmsDescription = SYNC_SMS_DESCRIPTION;
+        parcel.syncSmsTitle = SYNC_SMS_TITLE;
+        parcel.triggerDistance = TRIGGER_DISTANCE;
+        parcel.trueWirelessImageUrlCase = TRUE_WIRELESS_IMAGE_URL_CASE;
+        parcel.trueWirelessImageUrlLeftBud = TRUE_WIRELESS_IMAGE_URL_LEFT_BUD;
+        parcel.trueWirelessImageUrlRightBud = TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD;
+        parcel.unableToConnectDescription = UNABLE_TO_CONNECT_DESCRIPTION;
+        parcel.unableToConnectTitle = UNABLE_TO_CONNECT_TITLE;
+        parcel.updateCompanionAppDescription = UPDATE_COMPANION_APP_DESCRIPTION;
+        parcel.waitLaunchCompanionAppDescription = WAIT_LAUNCH_COMPANION_APP_DESCRIPTION;
+
+        return parcel;
+    }
+
+    /* Generates Happy Path FastPairEligibleAccount. */
+    private static FastPairEligibleAccount genHappyPathFastPairEligibleAccount(
+            Account account, boolean optIn) {
+        FastPairEligibleAccount.Builder builder = new FastPairEligibleAccount.Builder();
+        builder.setAccount(account);
+        builder.setOptIn(optIn);
+
+        return builder.build();
+    }
+
+    /* Verifies Happy Path AntispoofKeyDeviceMetadataRequest. */
+    private static void ensureHappyPathAsExpected(
+            FastPairDataProviderService.FastPairAntispoofKeyDeviceMetadataRequest request) {
+        assertThat(request.getModelId()).isEqualTo(REQUEST_MODEL_ID);
+    }
+
+    /* Verifies Happy Path AccountDevicesMetadataRequest. */
+    private static void ensureHappyPathAsExpected(
+            FastPairDataProviderService.FastPairAccountDevicesMetadataRequest request) {
+        assertThat(request.getAccount()).isEqualTo(ACCOUNTDEVICES_METADATA_ACCOUNT);
+        assertThat(request.getDeviceAccountKeys().size()).isEqualTo(ACCOUNTKEY_DEVICE_NUM);
+        assertThat(request.getDeviceAccountKeys()).contains(ACCOUNT_KEY);
+        assertThat(request.getDeviceAccountKeys()).contains(ACCOUNT_KEY_2);
+    }
+
+    /* Verifies Happy Path FastPairEligibleAccountsRequest. */
+    @SuppressWarnings("UnusedVariable")
+    private static void ensureHappyPathAsExpected(
+            FastPairDataProviderService.FastPairEligibleAccountsRequest request) {
+        // No fields since FastPairEligibleAccountsRequest is just a place holder now.
+    }
+
+    /* Verifies Happy Path FastPairManageAccountRequest. */
+    private static void ensureHappyPathAsExpected(
+            FastPairDataProviderService.FastPairManageAccountRequest request) {
+        assertThat(request.getAccount()).isEqualTo(MANAGE_ACCOUNT);
+        assertThat(request.getRequestType()).isEqualTo(MANAGE_ACCOUNT_REQUEST_TYPE);
+    }
+
+    /* Verifies Happy Path FastPairManageAccountDeviceRequest. */
+    private static void ensureHappyPathAsExpected(
+            FastPairDataProviderService.FastPairManageAccountDeviceRequest request) {
+        assertThat(request.getAccount()).isEqualTo(MANAGE_ACCOUNT);
+        assertThat(request.getRequestType()).isEqualTo(MANAGE_ACCOUNT_REQUEST_TYPE);
+        assertThat(request.getBleAddress()).isEqualTo(BLE_ADDRESS);
+        ensureHappyPathAsExpected(request.getAccountKeyDeviceMetadata());
+    }
+
+    /* Verifies Happy Path AntispoofKeyDeviceMetadataParcel. */
+    private static void ensureHappyPathAsExpected(
+            FastPairAntispoofKeyDeviceMetadataParcel metadataParcel) {
+        assertThat(metadataParcel).isNotNull();
+        assertThat(metadataParcel.antispoofPublicKey).isEqualTo(ANTI_SPOOFING_KEY);
+        ensureHappyPathAsExpected(metadataParcel.deviceMetadata);
+    }
+
+    /* Verifies Happy Path FastPairAccountKeyDeviceMetadataParcel[]. */
+    private static void ensureHappyPathAsExpected(
+            FastPairAccountKeyDeviceMetadataParcel[] metadataParcels) {
+        assertThat(metadataParcels).isNotNull();
+        assertThat(metadataParcels).hasLength(ACCOUNTKEY_DEVICE_NUM);
+        for (FastPairAccountKeyDeviceMetadataParcel parcel: metadataParcels) {
+            ensureHappyPathAsExpected(parcel);
+        }
+    }
+
+    /* Verifies Happy Path FastPairAccountKeyDeviceMetadataParcel. */
+    private static void ensureHappyPathAsExpected(
+            FastPairAccountKeyDeviceMetadataParcel metadataParcel) {
+        assertThat(metadataParcel).isNotNull();
+        assertThat(metadataParcel.deviceAccountKey).isEqualTo(ACCOUNT_KEY);
+        assertThat(metadataParcel.sha256DeviceAccountKeyPublicAddress)
+                .isEqualTo(SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS);
+        ensureHappyPathAsExpected(metadataParcel.metadata);
+        ensureHappyPathAsExpected(metadataParcel.discoveryItem);
+    }
+
+    /* Verifies Happy Path FastPairAccountKeyDeviceMetadata. */
+    private static void ensureHappyPathAsExpected(
+            FastPairAccountKeyDeviceMetadata metadata) {
+        assertThat(metadata.getDeviceAccountKey()).isEqualTo(ACCOUNT_KEY);
+        assertThat(metadata.getSha256DeviceAccountKeyPublicAddress())
+                .isEqualTo(SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS);
+        ensureHappyPathAsExpected(metadata.getFastPairDeviceMetadata());
+        ensureHappyPathAsExpected(metadata.getFastPairDiscoveryItem());
+    }
+
+    /* Verifies Happy Path DeviceMetadataParcel. */
+    private static void ensureHappyPathAsExpected(FastPairDeviceMetadataParcel metadataParcel) {
+        assertThat(metadataParcel).isNotNull();
+
+        assertThat(metadataParcel.assistantSetupHalfSheet).isEqualTo(ASSISTANT_SETUP_HALFSHEET);
+        assertThat(metadataParcel.assistantSetupNotification).isEqualTo(
+                ASSISTANT_SETUP_NOTIFICATION);
+
+        assertThat(metadataParcel.bleTxPower).isEqualTo(BLE_TX_POWER);
+
+        assertThat(metadataParcel.confirmPinDescription).isEqualTo(CONFIRM_PIN_DESCRIPTION);
+        assertThat(metadataParcel.confirmPinTitle).isEqualTo(CONFIRM_PIN_TITLE);
+        assertThat(metadataParcel.connectSuccessCompanionAppInstalled).isEqualTo(
+                CONNECT_SUCCESS_COMPANION_APP_INSTALLED);
+        assertThat(metadataParcel.connectSuccessCompanionAppNotInstalled).isEqualTo(
+                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED);
+
+        assertThat(metadataParcel.deviceType).isEqualTo(DEVICE_TYPE);
+        assertThat(metadataParcel.downloadCompanionAppDescription).isEqualTo(
+                DOWNLOAD_COMPANION_APP_DESCRIPTION);
+
+        assertThat(metadataParcel.failConnectGoToSettingsDescription).isEqualTo(
+                FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION);
+        assertThat(metadataParcel.fastPairTvConnectDeviceNoAccountDescription).isEqualTo(
+                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+
+        assertThat(metadataParcel.image).isEqualTo(IMAGE);
+        assertThat(metadataParcel.imageUrl).isEqualTo(IMAGE_URL);
+        assertThat(metadataParcel.initialNotificationDescription).isEqualTo(
+                INITIAL_NOTIFICATION_DESCRIPTION);
+        assertThat(metadataParcel.initialNotificationDescriptionNoAccount).isEqualTo(
+                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT);
+        assertThat(metadataParcel.initialPairingDescription).isEqualTo(INITIAL_PAIRING_DESCRIPTION);
+        assertThat(metadataParcel.intentUri).isEqualTo(INTENT_URI);
+
+        assertThat(metadataParcel.locale).isEqualTo(LOCALE);
+        assertThat(metadataParcel.name).isEqualTo(NAME);
+
+        assertThat(metadataParcel.openCompanionAppDescription).isEqualTo(
+                OPEN_COMPANION_APP_DESCRIPTION);
+
+        assertThat(metadataParcel.retroactivePairingDescription).isEqualTo(
+                RETRO_ACTIVE_PAIRING_DESCRIPTION);
+
+        assertThat(metadataParcel.subsequentPairingDescription).isEqualTo(
+                SUBSEQUENT_PAIRING_DESCRIPTION);
+        assertThat(metadataParcel.syncContactsDescription).isEqualTo(SYNC_CONTACT_DESCRPTION);
+        assertThat(metadataParcel.syncContactsTitle).isEqualTo(SYNC_CONTACTS_TITLE);
+        assertThat(metadataParcel.syncSmsDescription).isEqualTo(SYNC_SMS_DESCRIPTION);
+        assertThat(metadataParcel.syncSmsTitle).isEqualTo(SYNC_SMS_TITLE);
+
+        assertThat(metadataParcel.triggerDistance).isWithin(DELTA).of(TRIGGER_DISTANCE);
+        assertThat(metadataParcel.trueWirelessImageUrlCase).isEqualTo(TRUE_WIRELESS_IMAGE_URL_CASE);
+        assertThat(metadataParcel.trueWirelessImageUrlLeftBud).isEqualTo(
+                TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        assertThat(metadataParcel.trueWirelessImageUrlRightBud).isEqualTo(
+                TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+
+        assertThat(metadataParcel.unableToConnectDescription).isEqualTo(
+                UNABLE_TO_CONNECT_DESCRIPTION);
+        assertThat(metadataParcel.unableToConnectTitle).isEqualTo(UNABLE_TO_CONNECT_TITLE);
+        assertThat(metadataParcel.updateCompanionAppDescription).isEqualTo(
+                UPDATE_COMPANION_APP_DESCRIPTION);
+
+        assertThat(metadataParcel.waitLaunchCompanionAppDescription).isEqualTo(
+                WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+    }
+
+    /* Verifies Happy Path DeviceMetadata. */
+    private static void ensureHappyPathAsExpected(FastPairDeviceMetadata metadata) {
+        assertThat(metadata.getAssistantSetupHalfSheet()).isEqualTo(ASSISTANT_SETUP_HALFSHEET);
+        assertThat(metadata.getAssistantSetupNotification())
+                .isEqualTo(ASSISTANT_SETUP_NOTIFICATION);
+        assertThat(metadata.getBleTxPower()).isEqualTo(BLE_TX_POWER);
+        assertThat(metadata.getConfirmPinDescription()).isEqualTo(CONFIRM_PIN_DESCRIPTION);
+        assertThat(metadata.getConfirmPinTitle()).isEqualTo(CONFIRM_PIN_TITLE);
+        assertThat(metadata.getConnectSuccessCompanionAppInstalled())
+                .isEqualTo(CONNECT_SUCCESS_COMPANION_APP_INSTALLED);
+        assertThat(metadata.getConnectSuccessCompanionAppNotInstalled())
+                .isEqualTo(CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED);
+        assertThat(metadata.getDeviceType()).isEqualTo(DEVICE_TYPE);
+        assertThat(metadata.getDownloadCompanionAppDescription())
+                .isEqualTo(DOWNLOAD_COMPANION_APP_DESCRIPTION);
+        assertThat(metadata.getFailConnectGoToSettingsDescription())
+                .isEqualTo(FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION);
+        assertThat(metadata.getFastPairTvConnectDeviceNoAccountDescription())
+                .isEqualTo(FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+        assertThat(metadata.getImage()).isEqualTo(IMAGE);
+        assertThat(metadata.getImageUrl()).isEqualTo(IMAGE_URL);
+        assertThat(metadata.getInitialNotificationDescription())
+                .isEqualTo(INITIAL_NOTIFICATION_DESCRIPTION);
+        assertThat(metadata.getInitialNotificationDescriptionNoAccount())
+                .isEqualTo(INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT);
+        assertThat(metadata.getInitialPairingDescription()).isEqualTo(INITIAL_PAIRING_DESCRIPTION);
+        assertThat(metadata.getIntentUri()).isEqualTo(INTENT_URI);
+        assertThat(metadata.getLocale()).isEqualTo(LOCALE);
+        assertThat(metadata.getName()).isEqualTo(NAME);
+        assertThat(metadata.getOpenCompanionAppDescription())
+                .isEqualTo(OPEN_COMPANION_APP_DESCRIPTION);
+        assertThat(metadata.getRetroactivePairingDescription())
+                .isEqualTo(RETRO_ACTIVE_PAIRING_DESCRIPTION);
+        assertThat(metadata.getSubsequentPairingDescription())
+                .isEqualTo(SUBSEQUENT_PAIRING_DESCRIPTION);
+        assertThat(metadata.getSyncContactsDescription()).isEqualTo(SYNC_CONTACT_DESCRPTION);
+        assertThat(metadata.getSyncContactsTitle()).isEqualTo(SYNC_CONTACTS_TITLE);
+        assertThat(metadata.getSyncSmsDescription()).isEqualTo(SYNC_SMS_DESCRIPTION);
+        assertThat(metadata.getSyncSmsTitle()).isEqualTo(SYNC_SMS_TITLE);
+        assertThat(metadata.getTriggerDistance()).isWithin(DELTA).of(TRIGGER_DISTANCE);
+        assertThat(metadata.getTrueWirelessImageUrlCase()).isEqualTo(TRUE_WIRELESS_IMAGE_URL_CASE);
+        assertThat(metadata.getTrueWirelessImageUrlLeftBud())
+                .isEqualTo(TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        assertThat(metadata.getTrueWirelessImageUrlRightBud())
+                .isEqualTo(TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+        assertThat(metadata.getUnableToConnectDescription())
+                .isEqualTo(UNABLE_TO_CONNECT_DESCRIPTION);
+        assertThat(metadata.getUnableToConnectTitle()).isEqualTo(UNABLE_TO_CONNECT_TITLE);
+        assertThat(metadata.getUpdateCompanionAppDescription())
+                .isEqualTo(UPDATE_COMPANION_APP_DESCRIPTION);
+        assertThat(metadata.getWaitLaunchCompanionAppDescription())
+                .isEqualTo(WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+    }
+
+    /* Verifies Happy Path FastPairDiscoveryItemParcel. */
+    private static void ensureHappyPathAsExpected(FastPairDiscoveryItemParcel itemParcel) {
+        assertThat(itemParcel.actionUrl).isEqualTo(ACTION_URL);
+        assertThat(itemParcel.actionUrlType).isEqualTo(ACTION_URL_TYPE);
+        assertThat(itemParcel.appName).isEqualTo(APP_NAME);
+        assertThat(itemParcel.attachmentType).isEqualTo(ATTACHMENT_TYPE);
+        assertThat(itemParcel.authenticationPublicKeySecp256r1)
+                .isEqualTo(AUTHENTICATION_PUBLIC_KEY_SEC_P256R1);
+        assertThat(itemParcel.bleRecordBytes).isEqualTo(BLE_RECORD_BYTES);
+        assertThat(itemParcel.debugCategory).isEqualTo(DEBUG_CATEGORY);
+        assertThat(itemParcel.debugMessage).isEqualTo(DEBUG_MESSAGE);
+        assertThat(itemParcel.description).isEqualTo(DESCRIPTION);
+        assertThat(itemParcel.deviceName).isEqualTo(DEVICE_NAME);
+        assertThat(itemParcel.displayUrl).isEqualTo(DISPLAY_URL);
+        assertThat(itemParcel.entityId).isEqualTo(ENTITY_ID);
+        assertThat(itemParcel.featureGraphicUrl).isEqualTo(FEATURE_GRAPHIC_URL);
+        assertThat(itemParcel.firstObservationTimestampMillis)
+                .isEqualTo(FIRST_OBSERVATION_TIMESTAMP_MILLIS);
+        assertThat(itemParcel.groupId).isEqualTo(GROUP_ID);
+        assertThat(itemParcel.iconFifeUrl).isEqualTo(ICON_FIFE_URL);
+        assertThat(itemParcel.iconPng).isEqualTo(ICON_PNG);
+        assertThat(itemParcel.id).isEqualTo(ID);
+        assertThat(itemParcel.lastObservationTimestampMillis)
+                .isEqualTo(LAST_OBSERVATION_TIMESTAMP_MILLIS);
+        assertThat(itemParcel.lastUserExperience).isEqualTo(LAST_USER_EXPERIENCE);
+        assertThat(itemParcel.lostMillis).isEqualTo(LOST_MILLIS);
+        assertThat(itemParcel.macAddress).isEqualTo(MAC_ADDRESS);
+        assertThat(itemParcel.packageName).isEqualTo(PACKAGE_NAME);
+        assertThat(itemParcel.pendingAppInstallTimestampMillis)
+                .isEqualTo(PENDING_APP_INSTALL_TIMESTAMP_MILLIS);
+        assertThat(itemParcel.rssi).isEqualTo(RSSI);
+        assertThat(itemParcel.state).isEqualTo(STATE);
+        assertThat(itemParcel.title).isEqualTo(TITLE);
+        assertThat(itemParcel.triggerId).isEqualTo(TRIGGER_ID);
+        assertThat(itemParcel.txPower).isEqualTo(TX_POWER);
+        assertThat(itemParcel.type).isEqualTo(TYPE);
+    }
+
+    /* Verifies Happy Path FastPairDiscoveryItem. */
+    private static void ensureHappyPathAsExpected(FastPairDiscoveryItem item) {
+        assertThat(item.getActionUrl()).isEqualTo(ACTION_URL);
+        assertThat(item.getActionUrlType()).isEqualTo(ACTION_URL_TYPE);
+        assertThat(item.getAppName()).isEqualTo(APP_NAME);
+        assertThat(item.getAttachmentType()).isEqualTo(ATTACHMENT_TYPE);
+        assertThat(item.getAuthenticationPublicKeySecp256r1())
+                .isEqualTo(AUTHENTICATION_PUBLIC_KEY_SEC_P256R1);
+        assertThat(item.getBleRecordBytes()).isEqualTo(BLE_RECORD_BYTES);
+        assertThat(item.getDebugCategory()).isEqualTo(DEBUG_CATEGORY);
+        assertThat(item.getDebugMessage()).isEqualTo(DEBUG_MESSAGE);
+        assertThat(item.getDescription()).isEqualTo(DESCRIPTION);
+        assertThat(item.getDeviceName()).isEqualTo(DEVICE_NAME);
+        assertThat(item.getDisplayUrl()).isEqualTo(DISPLAY_URL);
+        assertThat(item.getEntityId()).isEqualTo(ENTITY_ID);
+        assertThat(item.getFeatureGraphicUrl()).isEqualTo(FEATURE_GRAPHIC_URL);
+        assertThat(item.getFirstObservationTimestampMillis())
+                .isEqualTo(FIRST_OBSERVATION_TIMESTAMP_MILLIS);
+        assertThat(item.getGroupId()).isEqualTo(GROUP_ID);
+        assertThat(item.getIconFfeUrl()).isEqualTo(ICON_FIFE_URL);
+        assertThat(item.getIconPng()).isEqualTo(ICON_PNG);
+        assertThat(item.getId()).isEqualTo(ID);
+        assertThat(item.getLastObservationTimestampMillis())
+                .isEqualTo(LAST_OBSERVATION_TIMESTAMP_MILLIS);
+        assertThat(item.getLastUserExperience()).isEqualTo(LAST_USER_EXPERIENCE);
+        assertThat(item.getLostMillis()).isEqualTo(LOST_MILLIS);
+        assertThat(item.getMacAddress()).isEqualTo(MAC_ADDRESS);
+        assertThat(item.getPackageName()).isEqualTo(PACKAGE_NAME);
+        assertThat(item.getPendingAppInstallTimestampMillis())
+                .isEqualTo(PENDING_APP_INSTALL_TIMESTAMP_MILLIS);
+        assertThat(item.getRssi()).isEqualTo(RSSI);
+        assertThat(item.getState()).isEqualTo(STATE);
+        assertThat(item.getTitle()).isEqualTo(TITLE);
+        assertThat(item.getTriggerId()).isEqualTo(TRIGGER_ID);
+        assertThat(item.getTxPower()).isEqualTo(TX_POWER);
+        assertThat(item.getType()).isEqualTo(TYPE);
+    }
+
+    /* Verifies Happy Path EligibleAccountParcel[]. */
+    private static void ensureHappyPathAsExpected(FastPairEligibleAccountParcel[] accountsParcel) {
+        assertThat(accountsParcel).hasLength(ELIGIBLE_ACCOUNTS_NUM);
+
+        assertThat(accountsParcel[0].account).isEqualTo(ELIGIBLE_ACCOUNT_1);
+        assertThat(accountsParcel[0].optIn).isEqualTo(ELIGIBLE_ACCOUNT_1_OPT_IN);
+
+        assertThat(accountsParcel[1].account).isEqualTo(ELIGIBLE_ACCOUNT_2);
+        assertThat(accountsParcel[1].optIn).isEqualTo(ELIGIBLE_ACCOUNT_2_OPT_IN);
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairEligibleAccountTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairEligibleAccountTest.java
new file mode 100644
index 0000000..0d91d4e
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/FastPairEligibleAccountTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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 android.nearby.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.accounts.Account;
+import android.nearby.FastPairEligibleAccount;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class FastPairEligibleAccountTest {
+
+    private static final Account ACCOUNT = new Account("abc@google.com", "type1");
+    private static final Account ACCOUNT_NULL = null;
+
+    private static final boolean OPT_IN_TRUE = true;
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetGetFastPairEligibleAccountNotNull() {
+        FastPairEligibleAccount eligibleAccount =
+                genFastPairEligibleAccount(ACCOUNT, OPT_IN_TRUE);
+
+        assertThat(eligibleAccount.getAccount()).isEqualTo(ACCOUNT);
+        assertThat(eligibleAccount.isOptIn()).isEqualTo(OPT_IN_TRUE);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetGetFastPairEligibleAccountNull() {
+        FastPairEligibleAccount eligibleAccount =
+                genFastPairEligibleAccount(ACCOUNT_NULL, OPT_IN_TRUE);
+
+        assertThat(eligibleAccount.getAccount()).isEqualTo(ACCOUNT_NULL);
+        assertThat(eligibleAccount.isOptIn()).isEqualTo(OPT_IN_TRUE);
+    }
+
+    /* Generates FastPairEligibleAccount. */
+    private static FastPairEligibleAccount genFastPairEligibleAccount(
+            Account account, boolean optIn) {
+        FastPairEligibleAccount.Builder builder = new FastPairEligibleAccount.Builder();
+        builder.setAccount(account);
+        builder.setOptIn(optIn);
+
+        return builder.build();
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceParcelableTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceParcelableTest.java
new file mode 100644
index 0000000..b9ab95f
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceParcelableTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.NearbyDevice;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PublicCredential;
+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;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class NearbyDeviceParcelableTest {
+
+    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 String FAST_PAIR_MODEL_ID = "1234";
+    private static final int RSSI = -60;
+
+    private NearbyDeviceParcelable.Builder mBuilder;
+
+    @Before
+    public void setUp() {
+        mBuilder =
+                new NearbyDeviceParcelable.Builder()
+                        .setName("testDevice")
+                        .setMedium(NearbyDevice.Medium.BLE)
+                        .setRssi(RSSI)
+                        .setFastPairModelId(FAST_PAIR_MODEL_ID)
+                        .setBluetoothAddress(BLUETOOTH_ADDRESS)
+                        .setData(SCAN_DATA);
+    }
+
+    /** Verify toString returns expected string. */
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testToString() {
+        PublicCredential publicCredential =
+                new PublicCredential.Builder(
+                                new byte[] {1},
+                                new byte[] {2},
+                                new byte[] {3},
+                                new byte[] {4},
+                                new byte[] {5})
+                        .build();
+        NearbyDeviceParcelable nearbyDeviceParcelable =
+                mBuilder.setFastPairModelId(null)
+                        .setData(null)
+                        .setPublicCredential(publicCredential)
+                        .build();
+
+        assertThat(nearbyDeviceParcelable.toString())
+                .isEqualTo(
+                        "NearbyDeviceParcelable[name=testDevice, medium=BLE, txPower=0, rssi=-60,"
+                                + " action=0, bluetoothAddress="
+                                + BLUETOOTH_ADDRESS
+                                + ", fastPairModelId=null, data=null]");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void test_defaultNullFields() {
+        NearbyDeviceParcelable nearbyDeviceParcelable =
+                new NearbyDeviceParcelable.Builder()
+                        .setMedium(NearbyDevice.Medium.BLE)
+                        .setRssi(RSSI)
+                        .build();
+
+        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.getRssi()).isEqualTo(RSSI);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testWriteParcel() {
+        NearbyDeviceParcelable nearbyDeviceParcelable = mBuilder.build();
+
+        Parcel parcel = Parcel.obtain();
+        nearbyDeviceParcelable.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        NearbyDeviceParcelable actualNearbyDevice =
+                NearbyDeviceParcelable.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+
+        assertThat(actualNearbyDevice.getRssi()).isEqualTo(RSSI);
+        assertThat(actualNearbyDevice.getFastPairModelId()).isEqualTo(FAST_PAIR_MODEL_ID);
+        assertThat(actualNearbyDevice.getBluetoothAddress()).isEqualTo(BLUETOOTH_ADDRESS);
+        assertThat(Arrays.equals(actualNearbyDevice.getData(), SCAN_DATA)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testWriteParcel_nullModelId() {
+        NearbyDeviceParcelable nearbyDeviceParcelable = mBuilder.setFastPairModelId(null).build();
+
+        Parcel parcel = Parcel.obtain();
+        nearbyDeviceParcelable.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        NearbyDeviceParcelable actualNearbyDevice =
+                NearbyDeviceParcelable.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+
+        assertThat(actualNearbyDevice.getFastPairModelId()).isNull();
+    }
+
+    @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);
+        NearbyDeviceParcelable actualNearbyDevice =
+                NearbyDeviceParcelable.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+
+        assertThat(actualNearbyDevice.getBluetoothAddress()).isNull();
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceTest.java
new file mode 100644
index 0000000..f37800a
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyDeviceTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.cts;
+
+import android.annotation.TargetApi;
+import android.nearby.FastPairDevice;
+import android.nearby.NearbyDevice;
+import android.os.Build;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.annotation.RequiresApi;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+@TargetApi(Build.VERSION_CODES.TIRAMISU)
+public class NearbyDeviceTest {
+
+    @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();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getMedium_fromChild() {
+        FastPairDevice fastPairDevice = new FastPairDevice.Builder()
+                .addMedium(NearbyDevice.Medium.BLE)
+                .setRssi(-60)
+                .build();
+
+        assertThat(fastPairDevice.getMediums()).contains(1);
+        assertThat(fastPairDevice.getRssi()).isEqualTo(-60);
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyFrameworkInitializerTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyFrameworkInitializerTest.java
new file mode 100644
index 0000000..cf43cb1
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyFrameworkInitializerTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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 android.nearby.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.nearby.NearbyFrameworkInitializer;
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+// NearbyFrameworkInitializer was added in T
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class NearbyFrameworkInitializerTest {
+
+    @Test
+    public void testServicesRegistered() {
+        Context ctx = InstrumentationRegistry.getInstrumentation().getContext();
+        assertThat(ctx.getSystemService(Context.NEARBY_SERVICE)).isNotNull();
+    }
+
+    // registerServiceWrappers can only be called during initialization and should throw otherwise
+    @Test(expected = IllegalStateException.class)
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testThrowsException() {
+        NearbyFrameworkInitializer.registerServiceWrappers();
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
new file mode 100644
index 0000000..acc7ec0
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.cts;
+
+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 com.google.common.truth.Truth.assertThat;
+
+import android.app.UiAutomation;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.cts.BTAdapterUtils;
+import android.content.Context;
+import android.nearby.BroadcastCallback;
+import android.nearby.BroadcastRequest;
+import android.nearby.NearbyDevice;
+import android.nearby.NearbyManager;
+import android.nearby.PresenceBroadcastRequest;
+import android.nearby.PrivateCredential;
+import android.nearby.ScanCallback;
+import android.nearby.ScanRequest;
+import android.os.Build;
+import android.provider.DeviceConfig;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.test.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * TODO(b/215435939) This class doesn't include any logic yet. Because SELinux denies access to
+ * NearbyManager.
+ */
+@RunWith(AndroidJUnit4.class)
+@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[] 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";
+    private static final int BLE_MEDIUM = 1;
+
+    private Context mContext;
+    private NearbyManager mNearbyManager;
+    private UiAutomation mUiAutomation =
+            InstrumentationRegistry.getInstrumentation().getUiAutomation();
+
+    @Before
+    public void setUp() {
+        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG);
+        DeviceConfig.setProperty(NAMESPACE_TETHERING, "nearby_enable_presence_broadcast_legacy",
+                "true", false);
+
+        mContext = InstrumentationRegistry.getContext();
+        mNearbyManager = mContext.getSystemService(NearbyManager.class);
+
+        enableBluetooth();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_startAndStopScan() {
+        ScanRequest scanRequest = new ScanRequest.Builder()
+                .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR)
+                .setScanMode(ScanRequest.SCAN_MODE_LOW_LATENCY)
+                .setBleEnabled(true)
+                .build();
+        ScanCallback scanCallback = new ScanCallback() {
+            @Override
+            public void onDiscovered(@NonNull NearbyDevice device) {
+            }
+
+            @Override
+            public void onUpdated(@NonNull NearbyDevice device) {
+
+            }
+
+            @Override
+            public void onLost(@NonNull NearbyDevice device) {
+
+            }
+        };
+        mNearbyManager.startScan(scanRequest, Executors.newSingleThreadExecutor(), scanCallback);
+        mNearbyManager.stopScan(scanCallback);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testStartStopBroadcast() throws InterruptedException {
+        PrivateCredential credential = new PrivateCredential.Builder(SECRETE_ID, AUTHENTICITY_KEY,
+                META_DATA_ENCRYPTION_KEY, DEVICE_NAME)
+                .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                .build();
+        BroadcastRequest broadcastRequest =
+                new PresenceBroadcastRequest.Builder(
+                        Collections.singletonList(BLE_MEDIUM), SALT, credential)
+                        .addAction(123)
+                        .build();
+
+        CountDownLatch latch = new CountDownLatch(1);
+        BroadcastCallback callback = status -> {
+            latch.countDown();
+            assertThat(status).isEqualTo(BroadcastCallback.STATUS_OK);
+        };
+        mNearbyManager.startBroadcast(broadcastRequest, Executors.newSingleThreadExecutor(),
+                callback);
+        latch.await(10, TimeUnit.SECONDS);
+        mNearbyManager.stopBroadcast(callback);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSettingsEnable() {
+        NearbyManager.setFastPairScanEnabled(mContext, false);
+        assertThat(NearbyManager.getFastPairScanEnabled(mContext)).isFalse();
+    }
+
+    private void enableBluetooth() {
+        BluetoothManager manager = mContext.getSystemService(BluetoothManager.class);
+        BluetoothAdapter bluetoothAdapter = manager.getAdapter();
+        if (!bluetoothAdapter.isEnabled()) {
+            assertThat(BTAdapterUtils.enableAdapter(bluetoothAdapter, mContext)).isTrue();
+        }
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceBroadcastRequestTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceBroadcastRequestTest.java
new file mode 100644
index 0000000..1daa410
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceBroadcastRequestTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.cts;
+
+import static android.nearby.BroadcastRequest.BROADCAST_TYPE_NEARBY_PRESENCE;
+import static android.nearby.BroadcastRequest.PRESENCE_VERSION_V0;
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.DataElement;
+import android.nearby.PresenceBroadcastRequest;
+import android.nearby.PrivateCredential;
+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;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+/**
+ * Tests for {@link PresenceBroadcastRequest}.
+ */
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class PresenceBroadcastRequestTest {
+
+    private static final int VERSION = PRESENCE_VERSION_V0;
+    private static final int TX_POWER = 1;
+    private static final byte[] SALT = new byte[]{1, 2};
+    private static final int ACTION_ID = 123;
+    private static final int BLE_MEDIUM = 1;
+    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[] METADATA_ENCRYPTION_KEY = new byte[]{1, 1, 3, 4, 5};
+    private static final int KEY = 1234;
+    private static final byte[] VALUE = new byte[]{1, 1, 1, 1};
+    private static final String DEVICE_NAME = "test_device";
+
+    private PresenceBroadcastRequest.Builder mBuilder;
+
+    @Before
+    public void setUp() {
+        PrivateCredential credential = new PrivateCredential.Builder(SECRETE_ID, AUTHENTICITY_KEY,
+                METADATA_ENCRYPTION_KEY, DEVICE_NAME)
+                .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                .build();
+        DataElement element = new DataElement(KEY, VALUE);
+        mBuilder = new PresenceBroadcastRequest.Builder(Collections.singletonList(BLE_MEDIUM), SALT,
+                credential)
+                .setTxPower(TX_POWER)
+                .setVersion(VERSION)
+                .addAction(ACTION_ID)
+                .addExtendedProperty(element);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBuilder() {
+        PresenceBroadcastRequest broadcastRequest = mBuilder.build();
+
+        assertThat(broadcastRequest.getVersion()).isEqualTo(VERSION);
+        assertThat(Arrays.equals(broadcastRequest.getSalt(), SALT)).isTrue();
+        assertThat(broadcastRequest.getTxPower()).isEqualTo(TX_POWER);
+        assertThat(broadcastRequest.getActions()).containsExactly(ACTION_ID);
+        assertThat(broadcastRequest.getExtendedProperties().get(0).getKey()).isEqualTo(
+                KEY);
+        assertThat(broadcastRequest.getMediums()).containsExactly(BLE_MEDIUM);
+        assertThat(broadcastRequest.getCredential().getIdentityType()).isEqualTo(
+                IDENTITY_TYPE_PRIVATE);
+        assertThat(broadcastRequest.getType()).isEqualTo(BROADCAST_TYPE_NEARBY_PRESENCE);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWriteParcel() {
+        PresenceBroadcastRequest broadcastRequest = mBuilder.build();
+
+        Parcel parcel = Parcel.obtain();
+        broadcastRequest.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        PresenceBroadcastRequest parcelRequest = PresenceBroadcastRequest.CREATOR.createFromParcel(
+                parcel);
+        parcel.recycle();
+
+        assertThat(parcelRequest.getTxPower()).isEqualTo(TX_POWER);
+        assertThat(parcelRequest.getActions()).containsExactly(ACTION_ID);
+        assertThat(parcelRequest.getExtendedProperties().get(0).getKey()).isEqualTo(
+                KEY);
+        assertThat(parcelRequest.getMediums()).containsExactly(BLE_MEDIUM);
+        assertThat(parcelRequest.getCredential().getIdentityType()).isEqualTo(
+                IDENTITY_TYPE_PRIVATE);
+        assertThat(parcelRequest.getType()).isEqualTo(BROADCAST_TYPE_NEARBY_PRESENCE);
+
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceDeviceTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceDeviceTest.java
new file mode 100644
index 0000000..5fefc68
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceDeviceTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.DataElement;
+import android.nearby.NearbyDevice;
+import android.nearby.PresenceDevice;
+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.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+/**
+ * Test for {@link PresenceDevice}.
+ */
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class PresenceDeviceTest {
+    private static final int DEVICE_TYPE = PresenceDevice.DeviceType.PHONE;
+    private static final String DEVICE_ID = "123";
+    private static final String IMAGE_URL = "http://example.com/imageUrl";
+    private static final int RSSI = -40;
+    private static final int MEDIUM = NearbyDevice.Medium.BLE;
+    private static final String DEVICE_NAME = "testDevice";
+    private static final int KEY = 1234;
+    private static final byte[] VALUE = new byte[]{1, 1, 1, 1};
+    private static final byte[] SALT = new byte[]{2, 3};
+    private static final byte[] SECRET_ID = new byte[]{11, 13};
+    private static final byte[] ENCRYPTED_IDENTITY = new byte[]{1, 3, 5, 61};
+    private static final long DISCOVERY_MILLIS = 100L;
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBuilder() {
+        PresenceDevice device =
+                new PresenceDevice.Builder(DEVICE_ID, SALT, SECRET_ID, ENCRYPTED_IDENTITY)
+                        .setDeviceType(DEVICE_TYPE)
+                        .setDeviceImageUrl(IMAGE_URL)
+                        .addExtendedProperty(new DataElement(KEY, VALUE))
+                        .setRssi(RSSI)
+                        .addMedium(MEDIUM)
+                        .setName(DEVICE_NAME)
+                        .setDiscoveryTimestampMillis(DISCOVERY_MILLIS)
+                        .build();
+
+        assertThat(device.getDeviceType()).isEqualTo(DEVICE_TYPE);
+        assertThat(device.getDeviceId()).isEqualTo(DEVICE_ID);
+        assertThat(device.getDeviceImageUrl()).isEqualTo(IMAGE_URL);
+        DataElement dataElement = device.getExtendedProperties().get(0);
+        assertThat(dataElement.getKey()).isEqualTo(KEY);
+        assertThat(Arrays.equals(dataElement.getValue(), VALUE)).isTrue();
+        assertThat(device.getRssi()).isEqualTo(RSSI);
+        assertThat(device.getMediums()).containsExactly(MEDIUM);
+        assertThat(device.getName()).isEqualTo(DEVICE_NAME);
+        assertThat(Arrays.equals(device.getSalt(), SALT)).isTrue();
+        assertThat(Arrays.equals(device.getSecretId(), SECRET_ID)).isTrue();
+        assertThat(Arrays.equals(device.getEncryptedIdentity(), ENCRYPTED_IDENTITY)).isTrue();
+        assertThat(device.getDiscoveryTimestampMillis()).isEqualTo(DISCOVERY_MILLIS);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWriteParcel() {
+        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();
+
+        Parcel parcel = Parcel.obtain();
+        device.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        PresenceDevice parcelDevice = PresenceDevice.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+
+        assertThat(parcelDevice.getDeviceId()).isEqualTo(DEVICE_ID);
+        assertThat(parcelDevice.getExtendedProperties().get(0).getKey()).isEqualTo(KEY);
+        assertThat(parcelDevice.getRssi()).isEqualTo(RSSI);
+        assertThat(parcelDevice.getMediums()).containsExactly(MEDIUM);
+        assertThat(parcelDevice.getName()).isEqualTo(DEVICE_NAME);
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceScanFilterTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceScanFilterTest.java
new file mode 100644
index 0000000..b7fe40a
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PresenceScanFilterTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.cts;
+
+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.ScanRequest;
+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.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Tests for {@link android.nearby.PresenceScanFilter}.
+ */
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class PresenceScanFilterTest {
+
+    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 = 1234;
+    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)
+                    .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                    .build();
+    private PresenceScanFilter.Builder mBuilder = new PresenceScanFilter.Builder()
+            .setMaxPathLoss(RSSI)
+            .addCredential(mPublicCredential)
+            .addPresenceAction(ACTION)
+            .addExtendedProperty(new DataElement(KEY, VALUE));
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBuilder() {
+        PresenceScanFilter filter = mBuilder.build();
+
+        assertThat(filter.getMaxPathLoss()).isEqualTo(RSSI);
+        assertThat(filter.getCredentials().get(0).getIdentityType()).isEqualTo(
+                IDENTITY_TYPE_PRIVATE);
+        assertThat(filter.getPresenceActions()).containsExactly(ACTION);
+        assertThat(filter.getExtendedProperties().get(0).getKey()).isEqualTo(KEY);
+
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWriteParcel() {
+        PresenceScanFilter filter = mBuilder.build();
+
+        Parcel parcel = Parcel.obtain();
+        filter.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        PresenceScanFilter parcelFilter = PresenceScanFilter.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+
+        assertThat(parcelFilter.getType()).isEqualTo(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE);
+        assertThat(parcelFilter.getMaxPathLoss()).isEqualTo(RSSI);
+        assertThat(parcelFilter.getPresenceActions()).containsExactly(ACTION);
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PrivateCredentialTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PrivateCredentialTest.java
new file mode 100644
index 0000000..f05f65f
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PrivateCredentialTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.cts;
+
+import static android.nearby.PresenceCredential.CREDENTIAL_TYPE_PRIVATE;
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.CredentialElement;
+import android.nearby.PrivateCredential;
+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;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+/**
+ * Tests for {@link PrivateCredential}.
+ */
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class PrivateCredentialTest {
+    private static final String DEVICE_NAME = "myDevice";
+    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 String KEY = "SecreteId";
+    private static final byte[] VALUE = new byte[]{1, 2, 3, 4, 5};
+    private static final byte[] METADATA_ENCRYPTION_KEY = new byte[]{1, 1, 3, 4, 5};
+
+    private PrivateCredential.Builder mBuilder;
+
+    @Before
+    public void setUp() {
+        mBuilder = new PrivateCredential.Builder(
+                SECRETE_ID, AUTHENTICITY_KEY, METADATA_ENCRYPTION_KEY, DEVICE_NAME)
+                .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                .addCredentialElement(new CredentialElement(KEY, VALUE));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testBuilder() {
+        PrivateCredential credential = mBuilder.build();
+
+        assertThat(credential.getType()).isEqualTo(CREDENTIAL_TYPE_PRIVATE);
+        assertThat(credential.getIdentityType()).isEqualTo(IDENTITY_TYPE_PRIVATE);
+        assertThat(credential.getDeviceName()).isEqualTo(DEVICE_NAME);
+        assertThat(Arrays.equals(credential.getSecretId(), SECRETE_ID)).isTrue();
+        assertThat(Arrays.equals(credential.getAuthenticityKey(), AUTHENTICITY_KEY)).isTrue();
+        assertThat(Arrays.equals(credential.getMetadataEncryptionKey(),
+                METADATA_ENCRYPTION_KEY)).isTrue();
+        CredentialElement credentialElement = credential.getCredentialElements().get(0);
+        assertThat(credentialElement.getKey()).isEqualTo(KEY);
+        assertThat(Arrays.equals(credentialElement.getValue(), VALUE)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 33, codeName = "T")
+    public void testWriteParcel() {
+        PrivateCredential credential = mBuilder.build();
+
+        Parcel parcel = Parcel.obtain();
+        credential.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        PrivateCredential credentialFromParcel = PrivateCredential.CREATOR.createFromParcel(
+                parcel);
+        parcel.recycle();
+
+        assertThat(credentialFromParcel.getType()).isEqualTo(CREDENTIAL_TYPE_PRIVATE);
+        assertThat(credentialFromParcel.getIdentityType()).isEqualTo(IDENTITY_TYPE_PRIVATE);
+        assertThat(Arrays.equals(credentialFromParcel.getSecretId(), SECRETE_ID)).isTrue();
+        assertThat(Arrays.equals(credentialFromParcel.getAuthenticityKey(),
+                AUTHENTICITY_KEY)).isTrue();
+        assertThat(Arrays.equals(credentialFromParcel.getMetadataEncryptionKey(),
+                METADATA_ENCRYPTION_KEY)).isTrue();
+        CredentialElement credentialElement = credentialFromParcel.getCredentialElements().get(0);
+        assertThat(credentialElement.getKey()).isEqualTo(KEY);
+        assertThat(Arrays.equals(credentialElement.getValue(), VALUE)).isTrue();
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/PublicCredentialTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/PublicCredentialTest.java
new file mode 100644
index 0000000..11bbacc
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/PublicCredentialTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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.cts;
+
+import static android.nearby.PresenceCredential.CREDENTIAL_TYPE_PUBLIC;
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.CredentialElement;
+import android.nearby.PresenceCredential;
+import android.nearby.PublicCredential;
+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;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+/** Tests for {@link PresenceCredential}. */
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class PublicCredentialTest {
+
+    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 String KEY = "KEY";
+    private static final byte[] VALUE = new byte[] {1, 2, 3, 4, 5};
+
+    private PublicCredential.Builder mBuilder;
+
+    @Before
+    public void setUp() {
+        mBuilder =
+                new PublicCredential.Builder(
+                                SECRETE_ID,
+                                AUTHENTICITY_KEY,
+                                PUBLIC_KEY,
+                                ENCRYPTED_METADATA,
+                                METADATA_ENCRYPTION_KEY_TAG)
+                        .addCredentialElement(new CredentialElement(KEY, VALUE))
+                        .setIdentityType(IDENTITY_TYPE_PRIVATE);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBuilder() {
+        PublicCredential credential = mBuilder.build();
+
+        assertThat(credential.getType()).isEqualTo(CREDENTIAL_TYPE_PUBLIC);
+        assertThat(credential.getIdentityType()).isEqualTo(IDENTITY_TYPE_PRIVATE);
+        assertThat(credential.getCredentialElements().get(0).getKey()).isEqualTo(KEY);
+        assertThat(Arrays.equals(credential.getSecretId(), SECRETE_ID)).isTrue();
+        assertThat(Arrays.equals(credential.getAuthenticityKey(), AUTHENTICITY_KEY)).isTrue();
+        assertThat(Arrays.equals(credential.getPublicKey(), PUBLIC_KEY)).isTrue();
+        assertThat(Arrays.equals(credential.getEncryptedMetadata(), ENCRYPTED_METADATA)).isTrue();
+        assertThat(
+                        Arrays.equals(
+                                credential.getEncryptedMetadataKeyTag(),
+                                METADATA_ENCRYPTION_KEY_TAG))
+                .isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWriteParcel() {
+        PublicCredential credential = mBuilder.build();
+
+        Parcel parcel = Parcel.obtain();
+        credential.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        PublicCredential credentialFromParcel = PublicCredential.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+
+        assertThat(credentialFromParcel.getType()).isEqualTo(CREDENTIAL_TYPE_PUBLIC);
+        assertThat(credentialFromParcel.getIdentityType()).isEqualTo(IDENTITY_TYPE_PRIVATE);
+        assertThat(Arrays.equals(credentialFromParcel.getSecretId(), SECRETE_ID)).isTrue();
+        assertThat(Arrays.equals(credentialFromParcel.getAuthenticityKey(), AUTHENTICITY_KEY))
+                .isTrue();
+        assertThat(Arrays.equals(credentialFromParcel.getPublicKey(), PUBLIC_KEY)).isTrue();
+        assertThat(Arrays.equals(credentialFromParcel.getEncryptedMetadata(), ENCRYPTED_METADATA))
+                .isTrue();
+        assertThat(
+                        Arrays.equals(
+                                credentialFromParcel.getEncryptedMetadataKeyTag(),
+                                METADATA_ENCRYPTION_KEY_TAG))
+                .isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEquals() {
+        PublicCredential credentialOne =
+                new PublicCredential.Builder(
+                                SECRETE_ID,
+                                AUTHENTICITY_KEY,
+                                PUBLIC_KEY,
+                                ENCRYPTED_METADATA,
+                                METADATA_ENCRYPTION_KEY_TAG)
+                        .addCredentialElement(new CredentialElement(KEY, VALUE))
+                        .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                        .build();
+
+        PublicCredential credentialTwo =
+                new PublicCredential.Builder(
+                                SECRETE_ID,
+                                AUTHENTICITY_KEY,
+                                PUBLIC_KEY,
+                                ENCRYPTED_METADATA,
+                                METADATA_ENCRYPTION_KEY_TAG)
+                        .addCredentialElement(new CredentialElement(KEY, VALUE))
+                        .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                        .build();
+        assertThat(credentialOne.equals((Object) credentialTwo)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testUnEquals() {
+        byte[] idOne = new byte[] {1, 2, 3, 4};
+        byte[] idTwo = new byte[] {4, 5, 6, 7};
+        PublicCredential credentialOne =
+                new PublicCredential.Builder(
+                                idOne,
+                                AUTHENTICITY_KEY,
+                                PUBLIC_KEY,
+                                ENCRYPTED_METADATA,
+                                METADATA_ENCRYPTION_KEY_TAG)
+                        .build();
+
+        PublicCredential credentialTwo =
+                new PublicCredential.Builder(
+                                idTwo,
+                                AUTHENTICITY_KEY,
+                                PUBLIC_KEY,
+                                ENCRYPTED_METADATA,
+                                METADATA_ENCRYPTION_KEY_TAG)
+                        .build();
+        assertThat(credentialOne.equals((Object) credentialTwo)).isFalse();
+    }
+}
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/ScanRequestTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/ScanRequestTest.java
new file mode 100644
index 0000000..019f8ec
--- /dev/null
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/ScanRequestTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.cts;
+
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+import static android.nearby.ScanRequest.SCAN_MODE_BALANCED;
+import static android.nearby.ScanRequest.SCAN_MODE_LOW_LATENCY;
+import static android.nearby.ScanRequest.SCAN_MODE_LOW_POWER;
+import static android.nearby.ScanRequest.SCAN_MODE_NO_POWER;
+import static android.nearby.ScanRequest.SCAN_TYPE_EXPOSURE_NOTIFICATION;
+import static android.nearby.ScanRequest.SCAN_TYPE_FAST_PAIR;
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_SHARE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.PresenceScanFilter;
+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;
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class ScanRequestTest {
+
+    private static final int UID = 1001;
+    private static final String APP_NAME = "android.nearby.tests";
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testScanType() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_PRESENCE)
+                .build();
+
+        assertThat(request.getScanType()).isEqualTo(SCAN_TYPE_NEARBY_PRESENCE);
+    }
+
+    // Valid scan type must be set to one of ScanRequest#SCAN_TYPE_
+    @Test(expected = IllegalStateException.class)
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testScanType_notSet_throwsException() {
+        new ScanRequest.Builder().setScanMode(SCAN_MODE_BALANCED).build();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testScanMode_defaultLowPower() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_FAST_PAIR)
+                .build();
+
+        assertThat(request.getScanMode()).isEqualTo(SCAN_MODE_LOW_POWER);
+    }
+
+    /** Verify setting work source with null value in the scan request is allowed */
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetWorkSource_nullValue() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_EXPOSURE_NOTIFICATION)
+                .setWorkSource(null)
+                .build();
+
+        // Null work source is allowed.
+        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_NEARBY_SHARE)
+                .setScanMode(SCAN_MODE_BALANCED)
+                .setBleEnabled(true)
+                .setWorkSource(workSource)
+                .build();
+
+        assertThat(request.toString()).isEqualTo(
+                "Request[scanType=2, 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() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_FAST_PAIR)
+                .build();
+
+        assertThat(request.isBleEnabled()).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_isValidScanType() {
+        assertThat(ScanRequest.isValidScanType(SCAN_TYPE_FAST_PAIR)).isTrue();
+        assertThat(ScanRequest.isValidScanType(SCAN_TYPE_NEARBY_SHARE)).isTrue();
+        assertThat(ScanRequest.isValidScanType(SCAN_TYPE_NEARBY_PRESENCE)).isTrue();
+        assertThat(ScanRequest.isValidScanType(SCAN_TYPE_EXPOSURE_NOTIFICATION)).isTrue();
+
+        assertThat(ScanRequest.isValidScanType(0)).isFalse();
+        assertThat(ScanRequest.isValidScanType(5)).isFalse();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_isValidScanMode() {
+        assertThat(ScanRequest.isValidScanMode(SCAN_MODE_LOW_LATENCY)).isTrue();
+        assertThat(ScanRequest.isValidScanMode(SCAN_MODE_BALANCED)).isTrue();
+        assertThat(ScanRequest.isValidScanMode(SCAN_MODE_LOW_POWER)).isTrue();
+        assertThat(ScanRequest.isValidScanMode(SCAN_MODE_NO_POWER)).isTrue();
+
+        assertThat(ScanRequest.isValidScanMode(3)).isFalse();
+        assertThat(ScanRequest.isValidScanMode(-2)).isFalse();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_scanModeToString() {
+        assertThat(ScanRequest.scanModeToString(2)).isEqualTo("SCAN_MODE_LOW_LATENCY");
+        assertThat(ScanRequest.scanModeToString(1)).isEqualTo("SCAN_MODE_BALANCED");
+        assertThat(ScanRequest.scanModeToString(0)).isEqualTo("SCAN_MODE_LOW_POWER");
+        assertThat(ScanRequest.scanModeToString(-1)).isEqualTo("SCAN_MODE_NO_POWER");
+
+        assertThat(ScanRequest.scanModeToString(3)).isEqualTo("SCAN_MODE_INVALID");
+        assertThat(ScanRequest.scanModeToString(-2)).isEqualTo("SCAN_MODE_INVALID");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testScanFilter() {
+        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 rssi = -40;
+        final int action = 123;
+        PresenceScanFilter filter = new PresenceScanFilter.Builder()
+                .addCredential(credential)
+                .setMaxPathLoss(rssi)
+                .addPresenceAction(action)
+                .build();
+
+        ScanRequest request = new ScanRequest.Builder().setScanType(
+                SCAN_TYPE_FAST_PAIR).addScanFilter(filter).build();
+
+        assertThat(request.getScanFilters()).isNotEmpty();
+        assertThat(request.getScanFilters().get(0).getMaxPathLoss()).isEqualTo(rssi);
+    }
+
+    private static WorkSource getWorkSource() {
+        return new WorkSource(UID, APP_NAME);
+    }
+}
diff --git a/nearby/tests/integration/OWNERS b/nearby/tests/integration/OWNERS
new file mode 100644
index 0000000..f4dbde2
--- /dev/null
+++ b/nearby/tests/integration/OWNERS
@@ -0,0 +1,4 @@
+# Bug component: 1092133
+
+ericth@google.com
+ryancllin@google.com
\ No newline at end of file
diff --git a/nearby/tests/integration/privileged/Android.bp b/nearby/tests/integration/privileged/Android.bp
new file mode 100644
index 0000000..e3250f6
--- /dev/null
+++ b/nearby/tests/integration/privileged/Android.bp
@@ -0,0 +1,33 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "NearbyIntegrationPrivilegedTests",
+    defaults: ["mts-target-sdk-version-current"],
+    sdk_version: "test_current",
+    certificate: "platform",
+
+    srcs: ["src/**/*.kt"],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "junit",
+        "truth-prebuilt",
+    ],
+    test_suites: ["device-tests"],
+}
diff --git a/nearby/tests/integration/privileged/AndroidManifest.xml b/nearby/tests/integration/privileged/AndroidManifest.xml
new file mode 100644
index 0000000..00845f1
--- /dev/null
+++ b/nearby/tests/integration/privileged/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="android.nearby.integration.privileged">
+
+    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.nearby.integration.privileged"
+        android:label="Nearby Mainline Module Integration Privileged Tests" />
+
+</manifest>
diff --git a/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/FastPairSettingsProviderTest.kt b/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/FastPairSettingsProviderTest.kt
new file mode 100644
index 0000000..af3f75f
--- /dev/null
+++ b/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/FastPairSettingsProviderTest.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.integration.privileged
+
+import android.content.Context
+import android.provider.Settings
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.junit.runners.Parameterized.Parameters
+
+data class FastPairSettingsFlag(val name: String, val value: Int) {
+    override fun toString() = name
+}
+
+@RunWith(Parameterized::class)
+class FastPairSettingsProviderTest(private val flag: FastPairSettingsFlag) {
+
+    /** Verify privileged app can enable/disable Fast Pair scan. */
+    @Test
+    fun testSettingsFastPairScan_fromPrivilegedApp() {
+        val appContext = ApplicationProvider.getApplicationContext<Context>()
+        val contentResolver = appContext.contentResolver
+
+        Settings.Secure.putInt(contentResolver, "fast_pair_scan_enabled", flag.value)
+
+        val actualValue = Settings.Secure.getInt(
+                contentResolver, "fast_pair_scan_enabled", /* default value */ -1)
+        assertThat(actualValue).isEqualTo(flag.value)
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameters(name = "{0}Succeed")
+        fun fastPairScanFlags() = listOf(
+            FastPairSettingsFlag(name = "disable", value = 0),
+            FastPairSettingsFlag(name = "enable", value = 1),
+        )
+    }
+}
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
new file mode 100644
index 0000000..3b6337a
--- /dev/null
+++ b/nearby/tests/integration/privileged/src/android/nearby/integration/privileged/NearbyManagerTest.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.integration.privileged
+
+import android.content.Context
+import android.nearby.NearbyManager
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class NearbyManagerTest {
+
+    /** Verify privileged app can get Nearby service. */
+    @Test
+    fun testContextGetNearbySystemService_fromPrivilegedApp_returnsNoneNull() {
+        val appContext = ApplicationProvider.getApplicationContext<Context>()
+        val nearbyManager = appContext.getSystemService(Context.NEARBY_SERVICE) as NearbyManager
+
+        assertThat(nearbyManager).isNotNull()
+    }
+}
diff --git a/nearby/tests/integration/untrusted/Android.bp b/nearby/tests/integration/untrusted/Android.bp
new file mode 100644
index 0000000..53dbfb7
--- /dev/null
+++ b/nearby/tests/integration/untrusted/Android.bp
@@ -0,0 +1,33 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "NearbyIntegrationUntrustedTests",
+    defaults: ["mts-target-sdk-version-current"],
+    sdk_version: "test_current",
+
+    srcs: ["src/**/*.kt"],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "junit",
+        "kotlin-test",
+        "truth-prebuilt",
+    ],
+    test_suites: ["device-tests"],
+}
diff --git a/nearby/tests/integration/untrusted/AndroidManifest.xml b/nearby/tests/integration/untrusted/AndroidManifest.xml
new file mode 100644
index 0000000..d73f6b2
--- /dev/null
+++ b/nearby/tests/integration/untrusted/AndroidManifest.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="android.nearby.integration.untrusted">
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.nearby.integration.untrusted"
+        android:label="Nearby Mainline Module Integration Untrusted Tests" />
+
+</manifest>
diff --git a/nearby/tests/integration/untrusted/src/android/nearby/integration/untrusted/FastPairSettingsProviderTest.kt b/nearby/tests/integration/untrusted/src/android/nearby/integration/untrusted/FastPairSettingsProviderTest.kt
new file mode 100644
index 0000000..c549073
--- /dev/null
+++ b/nearby/tests/integration/untrusted/src/android/nearby/integration/untrusted/FastPairSettingsProviderTest.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.integration.untrusted
+
+import android.content.Context
+import android.content.ContentResolver
+import android.provider.Settings
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertFailsWith
+
+
+@RunWith(AndroidJUnit4::class)
+class FastPairSettingsProviderTest {
+    private lateinit var contentResolver: ContentResolver
+
+    @Before
+    fun setUp() {
+        contentResolver = ApplicationProvider.getApplicationContext<Context>().contentResolver
+    }
+
+    /** Verify untrusted app can read Fast Pair scan enabled setting. */
+    @Test
+    fun testSettingsFastPairScan_fromUnTrustedApp_readsSucceed() {
+        Settings.Secure.getInt(contentResolver,
+                "fast_pair_scan_enabled", /* default value */ -1)
+    }
+
+    /** Verify untrusted app can't write Fast Pair scan enabled setting. */
+    @Test
+    fun testSettingsFastPairScan_fromUnTrustedApp_writesFailed() {
+        assertFailsWith<SecurityException> {
+            Settings.Secure.putInt(contentResolver, "fast_pair_scan_enabled", 1)
+        }
+    }
+}
diff --git a/nearby/tests/integration/untrusted/src/android/nearby/integration/untrusted/NearbyManagerTest.kt b/nearby/tests/integration/untrusted/src/android/nearby/integration/untrusted/NearbyManagerTest.kt
new file mode 100644
index 0000000..aa6aa2a
--- /dev/null
+++ b/nearby/tests/integration/untrusted/src/android/nearby/integration/untrusted/NearbyManagerTest.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.integration.untrusted
+
+import android.content.Context
+import android.nearby.NearbyManager
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class NearbyManagerTest {
+
+    /** Verify untrusted app can't get Nearby service. */
+    @Test
+    fun testContextGetNearbyService_fromUnTrustedApp_returnsNull() {
+        val appContext = ApplicationProvider.getApplicationContext<Context>()
+        assertThat(appContext.getSystemService(Context.NEARBY_SERVICE)).isNull()
+    }
+}
diff --git a/nearby/tests/multidevices/OWNERS b/nearby/tests/multidevices/OWNERS
new file mode 100644
index 0000000..f4dbde2
--- /dev/null
+++ b/nearby/tests/multidevices/OWNERS
@@ -0,0 +1,4 @@
+# Bug component: 1092133
+
+ericth@google.com
+ryancllin@google.com
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/Android.bp b/nearby/tests/multidevices/clients/Android.bp
new file mode 100644
index 0000000..49bc2e9
--- /dev/null
+++ b/nearby/tests/multidevices/clients/Android.bp
@@ -0,0 +1,46 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+    name: "NearbyMultiDevicesClientsLib",
+    srcs: ["src/**/*.kt"],
+    sdk_version: "test_current",
+    static_libs: [
+        "MoblySnippetHelperLib",
+        "NearbyFastPairProviderLib",
+        "NearbyFastPairSeekerSharedLib",
+        "androidx.test.core",
+        "androidx.test.ext.junit",
+        "androidx.test.uiautomator_uiautomator",
+        "kotlin-stdlib",
+        "mobly-snippet-lib",
+        "truth-prebuilt",
+    ],
+}
+
+android_app {
+    name: "NearbyMultiDevicesClientsSnippets",
+    sdk_version: "test_current",
+    certificate: "platform",
+    static_libs: ["NearbyMultiDevicesClientsLib"],
+    optimize: {
+        enabled: true,
+        shrink: false,
+        proguard_flags_files: ["proguard.flags"],
+    },
+}
diff --git a/nearby/tests/multidevices/clients/AndroidManifest.xml b/nearby/tests/multidevices/clients/AndroidManifest.xml
new file mode 100644
index 0000000..86c10b2
--- /dev/null
+++ b/nearby/tests/multidevices/clients/AndroidManifest.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.nearby.multidevices">
+
+    <uses-feature android:name="android.hardware.bluetooth" />
+    <uses-feature android:name="android.hardware.bluetooth_le" />
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+    <uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED" />
+    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.GET_ACCOUNTS"/>
+    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
+
+    <application>
+        <meta-data
+            android:name="mobly-log-tag"
+            android:value="NearbyMainlineSnippet" />
+        <meta-data
+            android:name="mobly-snippets"
+            android:value="android.nearby.multidevices.fastpair.seeker.FastPairSeekerSnippet,
+                           android.nearby.multidevices.fastpair.provider.FastPairProviderSimulatorSnippet" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:label="Nearby Mainline Module Instrumentation Test"
+        android:targetPackage="android.nearby.multidevices" />
+
+    <instrumentation
+        android:name="com.google.android.mobly.snippet.SnippetRunner"
+        android:label="Nearby Mainline Module Mobly Snippet"
+        android:targetPackage="android.nearby.multidevices" />
+
+</manifest>
diff --git a/nearby/tests/multidevices/clients/proguard.flags b/nearby/tests/multidevices/clients/proguard.flags
new file mode 100644
index 0000000..fd494a8
--- /dev/null
+++ b/nearby/tests/multidevices/clients/proguard.flags
@@ -0,0 +1,29 @@
+# Keep all snippet classes.
+-keep class android.nearby.multidevices.** {
+     *;
+}
+
+# Keep simulator reflection callback.
+-keep class android.nearby.fastpair.provider.** {
+     *;
+}
+
+# Do not touch Mobly.
+-keep class com.google.android.mobly.** {
+  *;
+}
+
+# Keep names for easy debugging.
+-dontobfuscate
+
+# Necessary to allow debugging.
+-keepattributes *
+
+# By default, proguard leaves all classes in their original package, which
+# needlessly repeats com.google.android.apps.etc.
+-repackageclasses ""
+
+# Allows proguard to make private and protected methods and fields public as
+# part of optimization. This lets proguard inline trivial getter/setter
+# methods.
+-allowaccessmodification
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/FastPairProviderSimulatorSnippet.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/FastPairProviderSimulatorSnippet.kt
new file mode 100644
index 0000000..4a8a772
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/FastPairProviderSimulatorSnippet.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.multidevices.fastpair.provider
+
+import android.annotation.TargetApi
+import android.content.Context
+import android.nearby.multidevices.fastpair.provider.controller.FastPairProviderSimulatorController
+import android.nearby.multidevices.fastpair.provider.events.ProviderStatusEvents
+import android.os.Build
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.mobly.snippet.Snippet
+import com.google.android.mobly.snippet.rpc.AsyncRpc
+import com.google.android.mobly.snippet.rpc.Rpc
+
+/** Expose Mobly RPC methods for Python side to simulate fast pair provider role. */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+class FastPairProviderSimulatorSnippet : Snippet {
+    private val context: Context = InstrumentationRegistry.getInstrumentation().context
+    private val fastPairProviderSimulatorController = FastPairProviderSimulatorController(context)
+
+    /** Sets up the Fast Pair provider simulator. */
+    @AsyncRpc(description = "Sets up FP provider simulator.")
+    fun setupProviderSimulator(callbackId: String) {
+        fastPairProviderSimulatorController.setupProviderSimulator(ProviderStatusEvents(callbackId))
+    }
+
+    /**
+     * Starts model id advertising for scanning and initial pairing.
+     *
+     * @param callbackId the callback ID corresponding to the
+     * [FastPairProviderSimulatorSnippet#startProviderSimulator] call that started the scanning.
+     * @param modelId a 3-byte hex string for seeker side to recognize the device (ex: 0x00000C).
+     * @param antiSpoofingKeyString a public key for registered headsets.
+     */
+    @AsyncRpc(description = "Starts model id advertising for scanning and initial pairing.")
+    fun startModelIdAdvertising(
+        callbackId: String,
+        modelId: String,
+        antiSpoofingKeyString: String
+    ) {
+        fastPairProviderSimulatorController.startModelIdAdvertising(
+            modelId,
+            antiSpoofingKeyString,
+            ProviderStatusEvents(callbackId)
+        )
+    }
+
+    /** Tears down the Fast Pair provider simulator. */
+    @Rpc(description = "Tears down FP provider simulator.")
+    fun teardownProviderSimulator() {
+        fastPairProviderSimulatorController.teardownProviderSimulator()
+    }
+
+    /** Gets BLE mac address of the Fast Pair provider simulator. */
+    @Rpc(description = "Gets BLE mac address of the Fast Pair provider simulator.")
+    fun getBluetoothLeAddress(): String {
+        return fastPairProviderSimulatorController.getProviderSimulatorBleAddress()
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/controller/FastPairProviderSimulatorController.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/controller/FastPairProviderSimulatorController.kt
new file mode 100644
index 0000000..b591ae0
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/controller/FastPairProviderSimulatorController.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.multidevices.fastpair.provider.controller
+
+import android.bluetooth.le.AdvertiseSettings
+import android.content.Context
+import android.nearby.fastpair.provider.FastPairSimulator
+import android.nearby.fastpair.provider.bluetooth.BluetoothController
+import com.google.android.mobly.snippet.util.Log
+import com.google.common.io.BaseEncoding
+
+class FastPairProviderSimulatorController(private val context: Context) :
+    FastPairSimulator.AdvertisingChangedCallback, BluetoothController.EventListener {
+    private lateinit var bluetoothController: BluetoothController
+    private lateinit var eventListener: EventListener
+    private var simulator: FastPairSimulator? = null
+
+    fun setupProviderSimulator(listener: EventListener) {
+        eventListener = listener
+
+        bluetoothController = BluetoothController(context, this)
+        bluetoothController.registerBluetoothStateReceiver()
+        bluetoothController.enableBluetooth()
+        bluetoothController.connectA2DPSinkProfile()
+    }
+
+    fun teardownProviderSimulator() {
+        simulator?.destroy()
+        bluetoothController.unregisterBluetoothStateReceiver()
+    }
+
+    fun startModelIdAdvertising(
+        modelId: String,
+        antiSpoofingKeyString: String,
+        listener: EventListener
+    ) {
+        eventListener = listener
+
+        val antiSpoofingKey = BaseEncoding.base64().decode(antiSpoofingKeyString)
+        simulator = FastPairSimulator(
+            context, FastPairSimulator.Options.builder(modelId)
+                .setAdvertisingModelId(modelId)
+                .setBluetoothAddress(null)
+                .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+                .setAdvertisingChangedCallback(this)
+                .setAntiSpoofingPrivateKey(antiSpoofingKey)
+                .setUseRandomSaltForAccountKeyRotation(false)
+                .setDataOnlyConnection(false)
+                .setShowsPasskeyConfirmation(false)
+                .setRemoveAllDevicesDuringPairing(true)
+                .build()
+        )
+
+        // TODO(b/222070055): Workaround the FATAL EXCEPTION after the end of initial pairing.
+        simulator!!.setSuppressSubsequentPairingNotification(true)
+    }
+
+    fun getProviderSimulatorBleAddress() = simulator!!.bleAddress!!
+
+    /**
+     * Called when we change our BLE advertisement.
+     *
+     * @param isAdvertising the advertising status.
+     */
+    override fun onAdvertisingChanged(isAdvertising: Boolean) {
+        Log.i("FastPairSimulator onAdvertisingChanged(isAdvertising: $isAdvertising)")
+        eventListener.onAdvertisingChange(isAdvertising)
+    }
+
+    /** The callback for the first onServiceConnected of A2DP sink profile. */
+    override fun onA2DPSinkProfileConnected() {
+        eventListener.onA2DPSinkProfileConnected()
+    }
+
+    /**
+     * Reports the current bond state of the remote device.
+     *
+     * @param bondState the bond state of the remote device.
+     */
+    override fun onBondStateChanged(bondState: Int) {
+    }
+
+    /**
+     * Reports the current connection state of the remote device.
+     *
+     * @param connectionState the bond state of the remote device.
+     */
+    override fun onConnectionStateChanged(connectionState: Int) {
+    }
+
+    /**
+     * Reports the current scan mode of the local Adapter.
+     *
+     * @param mode the current scan mode of the local Adapter.
+     */
+    override fun onScanModeChange(mode: Int) {
+        eventListener.onScanModeChange(FastPairSimulator.scanModeToString(mode))
+    }
+
+    /** Interface for listening the events from Fast Pair Provider Simulator. */
+    interface EventListener {
+        /** Reports the first onServiceConnected of A2DP sink profile. */
+        fun onA2DPSinkProfileConnected()
+
+        /**
+         * Reports the current scan mode of the local Adapter.
+         *
+         * @param mode the current scan mode in string.
+         */
+        fun onScanModeChange(mode: String)
+
+        /**
+         * Indicates the advertising state of the Fast Pair provider simulator has changed.
+         *
+         * @param isAdvertising the current advertising state, true if advertising otherwise false.
+         */
+        fun onAdvertisingChange(isAdvertising: Boolean)
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/events/ProviderStatusEvents.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/events/ProviderStatusEvents.kt
new file mode 100644
index 0000000..2addd77
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/events/ProviderStatusEvents.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.multidevices.fastpair.provider.events
+
+import android.nearby.multidevices.fastpair.provider.controller.FastPairProviderSimulatorController
+import com.google.android.mobly.snippet.util.postSnippetEvent
+
+/** The Mobly snippet events to report to the Python side. */
+class ProviderStatusEvents(private val callbackId: String) :
+    FastPairProviderSimulatorController.EventListener {
+
+    /** Reports the first onServiceConnected of A2DP sink profile. */
+    override fun onA2DPSinkProfileConnected() {
+        postSnippetEvent(callbackId, "onA2DPSinkProfileConnected") {}
+    }
+
+    /**
+     * Indicates the Bluetooth scan mode of the Fast Pair provider simulator has changed.
+     *
+     * @param mode the current scan mode in String mapping by [FastPairSimulator#scanModeToString].
+     */
+    override fun onScanModeChange(mode: String) {
+        postSnippetEvent(callbackId, "onScanModeChange") { putString("mode", mode) }
+    }
+
+    /**
+     * Indicates the advertising state of the Fast Pair provider simulator has changed.
+     *
+     * @param isAdvertising the current advertising state, true if advertising otherwise false.
+     */
+    override fun onAdvertisingChange(isAdvertising: Boolean) {
+        postSnippetEvent(callbackId, "onAdvertisingChange") {
+            putBoolean("isAdvertising", isAdvertising)
+        }
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairSeekerSnippet.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairSeekerSnippet.kt
new file mode 100644
index 0000000..65856d8
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairSeekerSnippet.kt
@@ -0,0 +1,152 @@
+/*
+ * 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.multidevices.fastpair.seeker
+
+import android.content.Context
+import android.nearby.FastPairDeviceMetadata
+import android.nearby.NearbyManager
+import android.nearby.ScanCallback
+import android.nearby.ScanRequest
+import android.nearby.fastpair.seeker.FAKE_TEST_ACCOUNT_NAME
+import android.nearby.multidevices.fastpair.seeker.data.FastPairTestDataManager
+import android.nearby.multidevices.fastpair.seeker.events.ScanCallbackEvents
+import android.nearby.multidevices.fastpair.seeker.ui.CheckNearbyHalfSheetUiTest
+import android.nearby.multidevices.fastpair.seeker.ui.DismissNearbyHalfSheetUiTest
+import androidx.test.core.app.ApplicationProvider
+import com.google.android.mobly.snippet.Snippet
+import com.google.android.mobly.snippet.rpc.AsyncRpc
+import com.google.android.mobly.snippet.rpc.Rpc
+import com.google.android.mobly.snippet.util.Log
+
+/** Expose Mobly RPC methods for Python side to test fast pair seeker role. */
+class FastPairSeekerSnippet : Snippet {
+    private val appContext = ApplicationProvider.getApplicationContext<Context>()
+    private val nearbyManager = appContext.getSystemService(Context.NEARBY_SERVICE) as NearbyManager
+    private val fastPairTestDataManager = FastPairTestDataManager(appContext)
+    private lateinit var scanCallback: ScanCallback
+
+    /**
+     * Starts scanning as a Fast Pair seeker to find provider devices.
+     *
+     * @param callbackId the callback ID corresponding to the {@link FastPairSeekerSnippet#startScan}
+     * call that started the scanning.
+     */
+    @AsyncRpc(description = "Starts scanning as Fast Pair seeker to find provider devices.")
+    fun startScan(callbackId: String) {
+        val scanRequest = ScanRequest.Builder()
+            .setScanMode(ScanRequest.SCAN_MODE_LOW_LATENCY)
+            .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR)
+            .setBleEnabled(true)
+            .build()
+        scanCallback = ScanCallbackEvents(callbackId)
+
+        Log.i("Start Fast Pair scanning via BLE...")
+        nearbyManager.startScan(scanRequest, /* executor */ { it.run() }, scanCallback)
+    }
+
+    /** Stops the Fast Pair seeker scanning. */
+    @Rpc(description = "Stops the Fast Pair seeker scanning.")
+    fun stopScan() {
+        Log.i("Stop Fast Pair scanning.")
+        nearbyManager.stopScan(scanCallback)
+    }
+
+    /** Waits and asserts the HalfSheet showed for Fast Pair pairing.
+     *
+     * @param modelId the expected model id to be associated with the HalfSheet.
+     * @param timeout the number of seconds to wait before giving up.
+     */
+    @Rpc(description = "Waits the HalfSheet showed for Fast Pair pairing.")
+    fun waitAndAssertHalfSheetShowed(modelId: String, timeout: Int) {
+        Log.i("Waits and asserts the HalfSheet showed for Fast Pair model $modelId.")
+
+        val deviceMetadata: FastPairDeviceMetadata =
+            fastPairTestDataManager.testDataCache.getFastPairDeviceMetadata(modelId)
+                ?: throw IllegalArgumentException(
+                    "Can't find $modelId-FastPairAntispoofKeyDeviceMetadata pair in " +
+                            "FastPairTestDataCache."
+                )
+        val deviceName = deviceMetadata.name!!
+        val initialPairingDescriptionTemplateText = deviceMetadata.initialPairingDescription!!
+
+        CheckNearbyHalfSheetUiTest(
+            waitHalfSheetPopupTimeoutSeconds = timeout,
+            halfSheetTitleText = deviceName,
+            halfSheetSubtitleText = initialPairingDescriptionTemplateText.format(
+                deviceName,
+                FAKE_TEST_ACCOUNT_NAME
+            )
+        ).checkNearbyHalfSheetUi()
+    }
+
+    /** Puts a model id to FastPairAntispoofKeyDeviceMetadata pair into test data cache.
+     *
+     * @param modelId a string of model id to be associated with.
+     * @param json a string of FastPairAntispoofKeyDeviceMetadata JSON object.
+     */
+    @Rpc(
+        description =
+        "Puts a model id to FastPairAntispoofKeyDeviceMetadata pair into test data cache."
+    )
+    fun putAntispoofKeyDeviceMetadata(modelId: String, json: String) {
+        Log.i("Puts a model id to FastPairAntispoofKeyDeviceMetadata pair into test data cache.")
+        fastPairTestDataManager.sendAntispoofKeyDeviceMetadata(modelId, json)
+    }
+
+    /** Puts an array of FastPairAccountKeyDeviceMetadata into test data cache.
+     *
+     * @param json a string of FastPairAccountKeyDeviceMetadata JSON array.
+     */
+    @Rpc(description = "Puts an array of FastPairAccountKeyDeviceMetadata into test data cache.")
+    fun putAccountKeyDeviceMetadata(json: String) {
+        Log.i("Puts an array of FastPairAccountKeyDeviceMetadata into test data cache.")
+        fastPairTestDataManager.sendAccountKeyDeviceMetadata(json)
+    }
+
+    /** Dumps all FastPairAccountKeyDeviceMetadata from the test data cache. */
+    @Rpc(description = "Dumps all FastPairAccountKeyDeviceMetadata from the test data cache.")
+    fun dumpAccountKeyDeviceMetadata(): String {
+        Log.i("Dumps all FastPairAccountKeyDeviceMetadata from the test data cache.")
+        return fastPairTestDataManager.testDataCache.dumpAccountKeyDeviceMetadataListAsJson()
+    }
+
+    /** Writes into {@link Settings} whether Fast Pair scan is enabled.
+     *
+     * @param enable whether the Fast Pair scan should be enabled.
+     */
+    @Rpc(description = "Writes into Settings whether Fast Pair scan is enabled.")
+    fun setFastPairScanEnabled(enable: Boolean) {
+        Log.i("Writes into Settings whether Fast Pair scan is enabled.")
+        NearbyManager.setFastPairScanEnabled(appContext, enable)
+    }
+
+    /** Dismisses the half sheet UI if showed. */
+    @Rpc(description = "Dismisses the half sheet UI if showed.")
+    fun dismissHalfSheet() {
+        Log.i("Dismisses the half sheet UI if showed.")
+
+        DismissNearbyHalfSheetUiTest().dismissHalfSheet()
+    }
+
+    /** Invokes when the snippet runner shutting down. */
+    override fun shutdown() {
+        super.shutdown()
+
+        Log.i("Resets the Fast Pair test data cache.")
+        fastPairTestDataManager.sendResetCache()
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/data/FastPairTestDataManager.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/data/FastPairTestDataManager.kt
new file mode 100644
index 0000000..291aad8
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/data/FastPairTestDataManager.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.multidevices.fastpair.seeker.data
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.nearby.fastpair.seeker.*
+import android.util.Log
+
+/** Manage local FastPairTestDataCache and send to/sync from the remote cache in data provider. */
+class FastPairTestDataManager(private val context: Context) : BroadcastReceiver() {
+    val testDataCache = FastPairTestDataCache()
+
+    /** Puts a model id to FastPairAntispoofKeyDeviceMetadata pair into local and remote cache.
+     *
+     * @param modelId a string of model id to be associated with.
+     * @param json a string of FastPairAntispoofKeyDeviceMetadata JSON object.
+     */
+    fun sendAntispoofKeyDeviceMetadata(modelId: String, json: String) {
+        Intent().also { intent ->
+            intent.action = ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA
+            intent.putExtra(DATA_MODEL_ID_STRING_KEY, modelId)
+            intent.putExtra(DATA_JSON_STRING_KEY, json)
+            context.sendBroadcast(intent)
+        }
+        testDataCache.putAntispoofKeyDeviceMetadata(modelId, json)
+    }
+
+    /** Puts account key device metadata to local and remote cache.
+     *
+     * @param json a string of FastPairAccountKeyDeviceMetadata JSON array.
+     */
+    fun sendAccountKeyDeviceMetadata(json: String) {
+        Intent().also { intent ->
+            intent.action = ACTION_SEND_ACCOUNT_KEY_DEVICE_METADATA
+            intent.putExtra(DATA_JSON_STRING_KEY, json)
+            context.sendBroadcast(intent)
+        }
+        testDataCache.putAccountKeyDeviceMetadata(json)
+    }
+
+    /** Clears local and remote cache. */
+    fun sendResetCache() {
+        context.sendBroadcast(Intent(ACTION_RESET_TEST_DATA_CACHE))
+        testDataCache.reset()
+    }
+
+    /**
+     * Callback method for receiving Intent broadcast from FastPairTestDataProvider.
+     *
+     * See [BroadcastReceiver#onReceive].
+     *
+     * @param context the Context in which the receiver is running.
+     * @param intent the Intent being received.
+     */
+    override fun onReceive(context: Context, intent: Intent) {
+        when (intent.action) {
+            ACTION_WRITE_ACCOUNT_KEY_DEVICE_METADATA -> {
+                Log.d(TAG, "ACTION_WRITE_ACCOUNT_KEY_DEVICE_METADATA received!")
+                val json = intent.getStringExtra(DATA_JSON_STRING_KEY)!!
+                testDataCache.putAccountKeyDeviceMetadata(json)
+            }
+            else -> Log.d(TAG, "Unknown action received!")
+        }
+    }
+
+    companion object {
+        private const val TAG = "FastPairTestDataManager"
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/events/ScanCallbackEvents.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/events/ScanCallbackEvents.kt
new file mode 100644
index 0000000..363355f
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/events/ScanCallbackEvents.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.multidevices.fastpair.seeker.events
+
+import android.nearby.NearbyDevice
+import android.nearby.ScanCallback
+import com.google.android.mobly.snippet.util.postSnippetEvent
+
+/** The Mobly snippet events to report to the Python side. */
+class ScanCallbackEvents(private val callbackId: String) : ScanCallback {
+
+    override fun onDiscovered(device: NearbyDevice) {
+        postSnippetEvent(callbackId, "onDiscovered") {
+            putString("device", device.toString())
+        }
+    }
+
+    override fun onUpdated(device: NearbyDevice) {
+        postSnippetEvent(callbackId, "onUpdated") {
+            putString("device", device.toString())
+        }
+    }
+
+    override fun onLost(device: NearbyDevice) {
+        postSnippetEvent(callbackId, "onLost") {
+            putString("device", device.toString())
+        }
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/CheckNearbyHalfSheetUiTest.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/CheckNearbyHalfSheetUiTest.kt
new file mode 100644
index 0000000..84b5e89
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/CheckNearbyHalfSheetUiTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.multidevices.fastpair.seeker.ui
+
+import android.os.Bundle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** An instrumented test to check Nearby half sheet UI showed correctly.
+ *
+ * To run this test directly:
+ * am instrument -w -r \
+ * -e class android.nearby.multidevices.fastpair.seeker.ui.CheckNearbyHalfSheetUiTest \
+ * android.nearby.multidevices/androidx.test.runner.AndroidJUnitRunner
+ */
+@RunWith(AndroidJUnit4::class)
+class CheckNearbyHalfSheetUiTest {
+    private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+    private val waitHalfSheetPopupTimeoutMs: Long
+    private val halfSheetTitleText: String
+    private val halfSheetSubtitleText: String
+
+    constructor() {
+        val arguments: Bundle = InstrumentationRegistry.getArguments()
+        waitHalfSheetPopupTimeoutMs = arguments.getLong(
+            WAIT_HALF_SHEET_POPUP_TIMEOUT_KEY,
+            DEFAULT_WAIT_HALF_SHEET_POPUP_TIMEOUT_MS
+        )
+        halfSheetTitleText =
+            arguments.getString(HALF_SHEET_TITLE_KEY, DEFAULT_HALF_SHEET_TITLE_TEXT)
+        halfSheetSubtitleText =
+            arguments.getString(HALF_SHEET_SUBTITLE_KEY, DEFAULT_HALF_SHEET_SUBTITLE_TEXT)
+    }
+
+    constructor(
+        waitHalfSheetPopupTimeoutSeconds: Int,
+        halfSheetTitleText: String,
+        halfSheetSubtitleText: String
+    ) {
+        this.waitHalfSheetPopupTimeoutMs = waitHalfSheetPopupTimeoutSeconds * 1000L
+        this.halfSheetTitleText = halfSheetTitleText
+        this.halfSheetSubtitleText = halfSheetSubtitleText
+    }
+
+    @Test
+    fun checkNearbyHalfSheetUi() {
+        // Check Nearby half sheet showed by checking button "Connect" on the DevicePairingFragment.
+        val isConnectButtonShowed = device.wait(
+            Until.hasObject(NearbyHalfSheetUiMap.DevicePairingFragment.connectButton),
+            waitHalfSheetPopupTimeoutMs
+        )
+        assertWithMessage("Nearby half sheet didn't show within $waitHalfSheetPopupTimeoutMs ms.")
+            .that(isConnectButtonShowed).isTrue()
+
+        val halfSheetTitle =
+            device.findObject(NearbyHalfSheetUiMap.DevicePairingFragment.halfSheetTitle)
+        assertThat(halfSheetTitle).isNotNull()
+        assertThat(halfSheetTitle.text).isEqualTo(halfSheetTitleText)
+
+        val halfSheetSubtitle =
+            device.findObject(NearbyHalfSheetUiMap.DevicePairingFragment.halfSheetSubtitle)
+        assertThat(halfSheetSubtitle).isNotNull()
+        assertThat(halfSheetSubtitle.text).isEqualTo(halfSheetSubtitleText)
+
+        val deviceImage = device.findObject(NearbyHalfSheetUiMap.DevicePairingFragment.deviceImage)
+        assertThat(deviceImage).isNotNull()
+
+        val infoButton = device.findObject(NearbyHalfSheetUiMap.DevicePairingFragment.infoButton)
+        assertThat(infoButton).isNotNull()
+    }
+
+    companion object {
+        private const val DEFAULT_WAIT_HALF_SHEET_POPUP_TIMEOUT_MS = 1000L
+        private const val DEFAULT_HALF_SHEET_TITLE_TEXT = "Fast Pair Provider Simulator"
+        private const val DEFAULT_HALF_SHEET_SUBTITLE_TEXT = "Fast Pair Provider Simulator will " +
+                "appear on devices linked with nearby-mainline-fpseeker@google.com"
+        private const val WAIT_HALF_SHEET_POPUP_TIMEOUT_KEY = "WAIT_HALF_SHEET_POPUP_TIMEOUT_MS"
+        private const val HALF_SHEET_TITLE_KEY = "HALF_SHEET_TITLE"
+        private const val HALF_SHEET_SUBTITLE_KEY = "HALF_SHEET_SUBTITLE"
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/DismissNearbyHalfSheetUiTest.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/DismissNearbyHalfSheetUiTest.kt
new file mode 100644
index 0000000..1d99d26
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/DismissNearbyHalfSheetUiTest.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.multidevices.fastpair.seeker.ui
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** An instrumented test to dismiss Nearby half sheet UI.
+ *
+ * To run this test directly:
+ * am instrument -w -r \
+ * -e class android.nearby.multidevices.fastpair.seeker.ui.DismissNearbyHalfSheetUiTest \
+ * android.nearby.multidevices/androidx.test.runner.AndroidJUnitRunner
+ */
+@RunWith(AndroidJUnit4::class)
+class DismissNearbyHalfSheetUiTest {
+    private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+    @Test
+    fun dismissHalfSheet() {
+        device.pressHome()
+        device.waitForIdle()
+
+        assertWithMessage("Fail to dismiss Nearby half sheet.").that(
+            device.findObject(NearbyHalfSheetUiMap.DevicePairingFragment.connectButton)
+        ).isNull()
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/NearbyHalfSheetUiMap.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/NearbyHalfSheetUiMap.kt
new file mode 100644
index 0000000..c94ff01
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ui/NearbyHalfSheetUiMap.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.multidevices.fastpair.seeker.ui
+
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.BySelector
+
+/** UiMap for Nearby Mainline Half Sheet. */
+object NearbyHalfSheetUiMap {
+    private const val PACKAGE_NAME = "com.google.android.nearby.halfsheet"
+    private const val ANDROID_WIDGET_BUTTON = "android.widget.Button"
+    private const val ANDROID_WIDGET_IMAGE_VIEW = "android.widget.ImageView"
+    private const val ANDROID_WIDGET_TEXT_VIEW = "android.widget.TextView"
+
+    object DevicePairingFragment {
+        val halfSheetTitle: BySelector =
+            By.res(PACKAGE_NAME, "toolbar_title").clazz(ANDROID_WIDGET_TEXT_VIEW)
+        val halfSheetSubtitle: BySelector =
+            By.res(PACKAGE_NAME, "header_subtitle").clazz(ANDROID_WIDGET_TEXT_VIEW)
+        val deviceImage: BySelector =
+            By.res(PACKAGE_NAME, "pairing_pic").clazz(ANDROID_WIDGET_IMAGE_VIEW)
+        val connectButton: BySelector =
+            By.res(PACKAGE_NAME, "connect_btn").clazz(ANDROID_WIDGET_BUTTON).text("Connect")
+        val infoButton: BySelector =
+            By.res(PACKAGE_NAME, "info_icon").clazz(ANDROID_WIDGET_IMAGE_VIEW)
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/Android.bp b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/Android.bp
new file mode 100644
index 0000000..a6d51ca
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/Android.bp
@@ -0,0 +1,46 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+    name: "NearbyFastPairSeekerSharedLib",
+    srcs: ["shared/**/*.kt"],
+    sdk_version: "test_current",
+    static_libs: [
+        "guava",
+        "gson-prebuilt-jar",
+    ],
+}
+
+android_library {
+    name: "NearbyFastPairSeekerDataProviderLib",
+    srcs: ["src/**/*.kt"],
+    sdk_version: "test_current",
+    static_libs: ["NearbyFastPairSeekerSharedLib"],
+}
+
+android_app {
+    name: "NearbyFastPairSeekerDataProvider",
+    sdk_version: "test_current",
+    certificate: "platform",
+    static_libs: ["NearbyFastPairSeekerDataProviderLib"],
+    optimize: {
+        enabled: true,
+        shrink: true,
+        proguard_flags_files: ["proguard.flags"],
+    },
+}
diff --git a/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/AndroidManifest.xml b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/AndroidManifest.xml
new file mode 100644
index 0000000..1d62f04
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/AndroidManifest.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.nearby.fastpair.seeker.dataprovider">
+
+    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
+
+    <application>
+        <!-- Fast Pair Data Provider Service which acts as an "overlay" to the
+             framework Fast Pair Data Provider. Only supported on Android T and later.
+             All overlays are protected from non-system access via WRITE_SECURE_SETTINGS.
+             Must stay in the same process as Nearby Discovery Service.
+        -->
+        <service
+            android:name=".FastPairTestDataProviderService"
+            android:exported="true"
+            android:permission="android.permission.WRITE_SECURE_SETTINGS"
+            android:visibleToInstantApps="true">
+            <intent-filter>
+                <action android:name="android.nearby.action.FAST_PAIR_DATA_PROVIDER" />
+            </intent-filter>
+
+            <meta-data
+                android:name="instantapps.clients.allowed"
+                android:value="true" />
+            <meta-data
+                android:name="serviceVersion"
+                android:value="1" />
+        </service>
+    </application>
+
+</manifest>
diff --git a/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/proguard.flags b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/proguard.flags
new file mode 100644
index 0000000..15debab
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/proguard.flags
@@ -0,0 +1,19 @@
+# Keep all receivers/service classes.
+-keep class android.nearby.fastpair.seeker.** {
+     *;
+}
+
+# Keep names for easy debugging.
+-dontobfuscate
+
+# Necessary to allow debugging.
+-keepattributes *
+
+# By default, proguard leaves all classes in their original package, which
+# needlessly repeats com.google.android.apps.etc.
+-repackageclasses ""
+
+# Allows proguard to make private and protected methods and fields public as
+# part of optimization. This lets proguard inline trivial getter/setter
+# methods.
+-allowaccessmodification
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/shared/android/nearby/fastpair/seeker/Constants.kt b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/shared/android/nearby/fastpair/seeker/Constants.kt
new file mode 100644
index 0000000..6070140
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/shared/android/nearby/fastpair/seeker/Constants.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.fastpair.seeker
+
+const val FAKE_TEST_ACCOUNT_NAME = "nearby-mainline-fpseeker@google.com"
+
+const val ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA =
+    "android.nearby.fastpair.seeker.action.ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA"
+const val ACTION_SEND_ACCOUNT_KEY_DEVICE_METADATA =
+    "android.nearby.fastpair.seeker.action.ACCOUNT_KEY_DEVICE_METADATA"
+const val ACTION_RESET_TEST_DATA_CACHE = "android.nearby.fastpair.seeker.action.RESET"
+const val ACTION_WRITE_ACCOUNT_KEY_DEVICE_METADATA =
+    "android.nearby.fastpair.seeker.action.WRITE_ACCOUNT_KEY_DEVICE_METADATA"
+
+const val DATA_JSON_STRING_KEY = "json"
+const val DATA_MODEL_ID_STRING_KEY = "modelId"
diff --git a/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/shared/android/nearby/fastpair/seeker/FastPairTestDataCache.kt b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/shared/android/nearby/fastpair/seeker/FastPairTestDataCache.kt
new file mode 100644
index 0000000..80ca855
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/shared/android/nearby/fastpair/seeker/FastPairTestDataCache.kt
@@ -0,0 +1,319 @@
+/*
+ * 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.fastpair.seeker
+
+import android.nearby.FastPairAccountKeyDeviceMetadata
+import android.nearby.FastPairAntispoofKeyDeviceMetadata
+import android.nearby.FastPairDeviceMetadata
+import android.nearby.FastPairDiscoveryItem
+import com.google.common.io.BaseEncoding
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+
+/** Manage a cache of Fast Pair test data for testing. */
+class FastPairTestDataCache {
+    private val gson = Gson()
+    private val accountKeyDeviceMetadataList = mutableListOf<FastPairAccountKeyDeviceMetadata>()
+    private val antispoofKeyDeviceMetadataDataMap =
+        mutableMapOf<String, FastPairAntispoofKeyDeviceMetadataData>()
+
+    fun putAccountKeyDeviceMetadata(json: String) {
+        accountKeyDeviceMetadataList +=
+            gson.fromJson(json, Array<FastPairAccountKeyDeviceMetadataData>::class.java)
+                .map { it.toFastPairAccountKeyDeviceMetadata() }
+    }
+
+    fun putAccountKeyDeviceMetadata(accountKeyDeviceMetadata: FastPairAccountKeyDeviceMetadata) {
+        accountKeyDeviceMetadataList += accountKeyDeviceMetadata
+    }
+
+    fun getAccountKeyDeviceMetadataList(): List<FastPairAccountKeyDeviceMetadata> =
+        accountKeyDeviceMetadataList.toList()
+
+    fun dumpAccountKeyDeviceMetadataAsJson(metadata: FastPairAccountKeyDeviceMetadata): String =
+        gson.toJson(FastPairAccountKeyDeviceMetadataData(metadata))
+
+    fun dumpAccountKeyDeviceMetadataListAsJson(): String =
+        gson.toJson(accountKeyDeviceMetadataList.map { FastPairAccountKeyDeviceMetadataData(it) })
+
+    fun putAntispoofKeyDeviceMetadata(modelId: String, json: String) {
+        antispoofKeyDeviceMetadataDataMap[modelId] =
+            gson.fromJson(json, FastPairAntispoofKeyDeviceMetadataData::class.java)
+    }
+
+    fun getAntispoofKeyDeviceMetadata(modelId: String): FastPairAntispoofKeyDeviceMetadata? {
+        return antispoofKeyDeviceMetadataDataMap[modelId]?.toFastPairAntispoofKeyDeviceMetadata()
+    }
+
+    fun getFastPairDeviceMetadata(modelId: String): FastPairDeviceMetadata? =
+        antispoofKeyDeviceMetadataDataMap[modelId]?.deviceMeta?.toFastPairDeviceMetadata()
+
+    fun reset() {
+        accountKeyDeviceMetadataList.clear()
+        antispoofKeyDeviceMetadataDataMap.clear()
+    }
+
+    data class FastPairAccountKeyDeviceMetadataData(
+        @SerializedName("account_key") val accountKey: String?,
+        @SerializedName("sha256_account_key_public_address") val accountKeyPublicAddress: String?,
+        @SerializedName("fast_pair_device_metadata") val deviceMeta: FastPairDeviceMetadataData?,
+        @SerializedName("fast_pair_discovery_item") val discoveryItem: FastPairDiscoveryItemData?,
+    ) {
+        constructor(meta: FastPairAccountKeyDeviceMetadata) : this(
+            accountKey = meta.deviceAccountKey?.base64Encode(),
+            accountKeyPublicAddress = meta.sha256DeviceAccountKeyPublicAddress?.base64Encode(),
+            deviceMeta = meta.fastPairDeviceMetadata?.let { FastPairDeviceMetadataData(it) },
+            discoveryItem = meta.fastPairDiscoveryItem?.let { FastPairDiscoveryItemData(it) },
+        )
+
+        fun toFastPairAccountKeyDeviceMetadata(): FastPairAccountKeyDeviceMetadata {
+            return FastPairAccountKeyDeviceMetadata.Builder()
+                .setDeviceAccountKey(accountKey?.base64Decode())
+                .setSha256DeviceAccountKeyPublicAddress(accountKeyPublicAddress?.base64Decode())
+                .setFastPairDeviceMetadata(deviceMeta?.toFastPairDeviceMetadata())
+                .setFastPairDiscoveryItem(discoveryItem?.toFastPairDiscoveryItem())
+                .build()
+        }
+    }
+
+    data class FastPairAntispoofKeyDeviceMetadataData(
+        @SerializedName("anti_spoofing_public_key_str") val antispoofPublicKey: String?,
+        @SerializedName("fast_pair_device_metadata") val deviceMeta: FastPairDeviceMetadataData?,
+    ) {
+        fun toFastPairAntispoofKeyDeviceMetadata(): FastPairAntispoofKeyDeviceMetadata {
+            return FastPairAntispoofKeyDeviceMetadata.Builder()
+                .setAntispoofPublicKey(antispoofPublicKey?.base64Decode())
+                .setFastPairDeviceMetadata(deviceMeta?.toFastPairDeviceMetadata())
+                .build()
+        }
+    }
+
+    data class FastPairDeviceMetadataData(
+        @SerializedName("assistant_setup_half_sheet") val assistantSetupHalfSheet: String?,
+        @SerializedName("assistant_setup_notification") val assistantSetupNotification: String?,
+        @SerializedName("ble_tx_power") val bleTxPower: Int,
+        @SerializedName("confirm_pin_description") val confirmPinDescription: String?,
+        @SerializedName("confirm_pin_title") val confirmPinTitle: String?,
+        @SerializedName("connect_success_companion_app_installed") val compAppInstalled: String?,
+        @SerializedName("connect_success_companion_app_not_installed") val comAppNotIns: String?,
+        @SerializedName("device_type") val deviceType: Int,
+        @SerializedName("download_companion_app_description") val downloadComApp: String?,
+        @SerializedName("fail_connect_go_to_settings_description") val failConnectDes: String?,
+        @SerializedName("fast_pair_tv_connect_device_no_account_description") val accDes: String?,
+        @SerializedName("image_url") val imageUrl: String?,
+        @SerializedName("initial_notification_description") val initNotification: String?,
+        @SerializedName("initial_notification_description_no_account") val initNoAccount: String?,
+        @SerializedName("initial_pairing_description") val initialPairingDescription: String?,
+        @SerializedName("intent_uri") val intentUri: String?,
+        @SerializedName("locale") val locale: String?,
+        @SerializedName("name") val name: String?,
+        @SerializedName("open_companion_app_description") val openCompanionAppDescription: String?,
+        @SerializedName("retroactive_pairing_description") val retroactivePairingDes: String?,
+        @SerializedName("subsequent_pairing_description") val subsequentPairingDescription: String?,
+        @SerializedName("sync_contacts_description") val syncContactsDescription: String?,
+        @SerializedName("sync_contacts_title") val syncContactsTitle: String?,
+        @SerializedName("sync_sms_description") val syncSmsDescription: String?,
+        @SerializedName("sync_sms_title") val syncSmsTitle: String?,
+        @SerializedName("trigger_distance") val triggerDistance: Double,
+        @SerializedName("case_url") val trueWirelessImageUrlCase: String?,
+        @SerializedName("left_bud_url") val trueWirelessImageUrlLeftBud: String?,
+        @SerializedName("right_bud_url") val trueWirelessImageUrlRightBud: String?,
+        @SerializedName("unable_to_connect_description") val unableToConnectDescription: String?,
+        @SerializedName("unable_to_connect_title") val unableToConnectTitle: String?,
+        @SerializedName("update_companion_app_description") val updateCompAppDes: String?,
+        @SerializedName("wait_launch_companion_app_description") val waitLaunchCompApp: String?,
+    ) {
+        constructor(meta: FastPairDeviceMetadata) : this(
+            assistantSetupHalfSheet = meta.assistantSetupHalfSheet,
+            assistantSetupNotification = meta.assistantSetupNotification,
+            bleTxPower = meta.bleTxPower,
+            confirmPinDescription = meta.confirmPinDescription,
+            confirmPinTitle = meta.confirmPinTitle,
+            compAppInstalled = meta.connectSuccessCompanionAppInstalled,
+            comAppNotIns = meta.connectSuccessCompanionAppNotInstalled,
+            deviceType = meta.deviceType,
+            downloadComApp = meta.downloadCompanionAppDescription,
+            failConnectDes = meta.failConnectGoToSettingsDescription,
+            accDes = meta.fastPairTvConnectDeviceNoAccountDescription,
+            imageUrl = meta.imageUrl,
+            initNotification = meta.initialNotificationDescription,
+            initNoAccount = meta.initialNotificationDescriptionNoAccount,
+            initialPairingDescription = meta.initialPairingDescription,
+            intentUri = meta.intentUri,
+            locale = meta.locale,
+            name = meta.name,
+            openCompanionAppDescription = meta.openCompanionAppDescription,
+            retroactivePairingDes = meta.retroactivePairingDescription,
+            subsequentPairingDescription = meta.subsequentPairingDescription,
+            syncContactsDescription = meta.syncContactsDescription,
+            syncContactsTitle = meta.syncContactsTitle,
+            syncSmsDescription = meta.syncSmsDescription,
+            syncSmsTitle = meta.syncSmsTitle,
+            triggerDistance = meta.triggerDistance.toDouble(),
+            trueWirelessImageUrlCase = meta.trueWirelessImageUrlCase,
+            trueWirelessImageUrlLeftBud = meta.trueWirelessImageUrlLeftBud,
+            trueWirelessImageUrlRightBud = meta.trueWirelessImageUrlRightBud,
+            unableToConnectDescription = meta.unableToConnectDescription,
+            unableToConnectTitle = meta.unableToConnectTitle,
+            updateCompAppDes = meta.updateCompanionAppDescription,
+            waitLaunchCompApp = meta.waitLaunchCompanionAppDescription,
+        )
+
+        fun toFastPairDeviceMetadata(): FastPairDeviceMetadata {
+            return FastPairDeviceMetadata.Builder()
+                .setAssistantSetupHalfSheet(assistantSetupHalfSheet)
+                .setAssistantSetupNotification(assistantSetupNotification)
+                .setBleTxPower(bleTxPower)
+                .setConfirmPinDescription(confirmPinDescription)
+                .setConfirmPinTitle(confirmPinTitle)
+                .setConnectSuccessCompanionAppInstalled(compAppInstalled)
+                .setConnectSuccessCompanionAppNotInstalled(comAppNotIns)
+                .setDeviceType(deviceType)
+                .setDownloadCompanionAppDescription(downloadComApp)
+                .setFailConnectGoToSettingsDescription(failConnectDes)
+                .setFastPairTvConnectDeviceNoAccountDescription(accDes)
+                .setImageUrl(imageUrl)
+                .setInitialNotificationDescription(initNotification)
+                .setInitialNotificationDescriptionNoAccount(initNoAccount)
+                .setInitialPairingDescription(initialPairingDescription)
+                .setIntentUri(intentUri)
+                .setLocale(locale)
+                .setName(name)
+                .setOpenCompanionAppDescription(openCompanionAppDescription)
+                .setRetroactivePairingDescription(retroactivePairingDes)
+                .setSubsequentPairingDescription(subsequentPairingDescription)
+                .setSyncContactsDescription(syncContactsDescription)
+                .setSyncContactsTitle(syncContactsTitle)
+                .setSyncSmsDescription(syncSmsDescription)
+                .setSyncSmsTitle(syncSmsTitle)
+                .setTriggerDistance(triggerDistance.toFloat())
+                .setTrueWirelessImageUrlCase(trueWirelessImageUrlCase)
+                .setTrueWirelessImageUrlLeftBud(trueWirelessImageUrlLeftBud)
+                .setTrueWirelessImageUrlRightBud(trueWirelessImageUrlRightBud)
+                .setUnableToConnectDescription(unableToConnectDescription)
+                .setUnableToConnectTitle(unableToConnectTitle)
+                .setUpdateCompanionAppDescription(updateCompAppDes)
+                .setWaitLaunchCompanionAppDescription(waitLaunchCompApp)
+                .build()
+        }
+    }
+
+    data class FastPairDiscoveryItemData(
+        @SerializedName("action_url") val actionUrl: String?,
+        @SerializedName("action_url_type") val actionUrlType: Int,
+        @SerializedName("app_name") val appName: String?,
+        @SerializedName("attachment_type") val attachmentType: Int,
+        @SerializedName("authentication_public_key_secp256r1") val authenticationPublicKey: String?,
+        @SerializedName("ble_record_bytes") val bleRecordBytes: String?,
+        @SerializedName("debug_category") val debugCategory: Int,
+        @SerializedName("debug_message") val debugMessage: String?,
+        @SerializedName("description") val description: String?,
+        @SerializedName("device_name") val deviceName: String?,
+        @SerializedName("display_url") val displayUrl: String?,
+        @SerializedName("entity_id") val entityId: String?,
+        @SerializedName("feature_graphic_url") val featureGraphicUrl: String?,
+        @SerializedName("first_observation_timestamp_millis") val firstObservationMs: Long,
+        @SerializedName("group_id") val groupId: String?,
+        @SerializedName("icon_fife_url") val iconFfeUrl: String?,
+        @SerializedName("icon_png") val iconPng: String?,
+        @SerializedName("id") val id: String?,
+        @SerializedName("last_observation_timestamp_millis") val lastObservationMs: Long,
+        @SerializedName("last_user_experience") val lastUserExperience: Int,
+        @SerializedName("lost_millis") val lostMillis: Long,
+        @SerializedName("mac_address") val macAddress: String?,
+        @SerializedName("package_name") val packageName: String?,
+        @SerializedName("pending_app_install_timestamp_millis") val pendingAppInstallMs: Long,
+        @SerializedName("rssi") val rssi: Int,
+        @SerializedName("state") val state: Int,
+        @SerializedName("title") val title: String?,
+        @SerializedName("trigger_id") val triggerId: String?,
+        @SerializedName("tx_power") val txPower: Int,
+        @SerializedName("type") val type: Int,
+    ) {
+        constructor(item: FastPairDiscoveryItem) : this(
+            actionUrl = item.actionUrl,
+            actionUrlType = item.actionUrlType,
+            appName = item.appName,
+            attachmentType = item.attachmentType,
+            authenticationPublicKey = item.authenticationPublicKeySecp256r1?.base64Encode(),
+            bleRecordBytes = item.bleRecordBytes?.base64Encode(),
+            debugCategory = item.debugCategory,
+            debugMessage = item.debugMessage,
+            description = item.description,
+            deviceName = item.deviceName,
+            displayUrl = item.displayUrl,
+            entityId = item.entityId,
+            featureGraphicUrl = item.featureGraphicUrl,
+            firstObservationMs = item.firstObservationTimestampMillis,
+            groupId = item.groupId,
+            iconFfeUrl = item.iconFfeUrl,
+            iconPng = item.iconPng?.base64Encode(),
+            id = item.id,
+            lastObservationMs = item.lastObservationTimestampMillis,
+            lastUserExperience = item.lastUserExperience,
+            lostMillis = item.lostMillis,
+            macAddress = item.macAddress,
+            packageName = item.packageName,
+            pendingAppInstallMs = item.pendingAppInstallTimestampMillis,
+            rssi = item.rssi,
+            state = item.state,
+            title = item.title,
+            triggerId = item.triggerId,
+            txPower = item.txPower,
+            type = item.type,
+        )
+
+        fun toFastPairDiscoveryItem(): FastPairDiscoveryItem {
+            return FastPairDiscoveryItem.Builder()
+                .setActionUrl(actionUrl)
+                .setActionUrlType(actionUrlType)
+                .setAppName(appName)
+                .setAttachmentType(attachmentType)
+                .setAuthenticationPublicKeySecp256r1(authenticationPublicKey?.base64Decode())
+                .setBleRecordBytes(bleRecordBytes?.base64Decode())
+                .setDebugCategory(debugCategory)
+                .setDebugMessage(debugMessage)
+                .setDescription(description)
+                .setDeviceName(deviceName)
+                .setDisplayUrl(displayUrl)
+                .setEntityId(entityId)
+                .setFeatureGraphicUrl(featureGraphicUrl)
+                .setFirstObservationTimestampMillis(firstObservationMs)
+                .setGroupId(groupId)
+                .setIconFfeUrl(iconFfeUrl)
+                .setIconPng(iconPng?.base64Decode())
+                .setId(id)
+                .setLastObservationTimestampMillis(lastObservationMs)
+                .setLastUserExperience(lastUserExperience)
+                .setLostMillis(lostMillis)
+                .setMacAddress(macAddress)
+                .setPackageName(packageName)
+                .setPendingAppInstallTimestampMillis(pendingAppInstallMs)
+                .setRssi(rssi)
+                .setState(state)
+                .setTitle(title)
+                .setTriggerId(triggerId)
+                .setTxPower(txPower)
+                .setType(type)
+                .build()
+        }
+    }
+}
+
+private fun String.base64Decode(): ByteArray = BaseEncoding.base64().decode(this)
+
+private fun ByteArray.base64Encode(): String = BaseEncoding.base64().encode(this)
diff --git a/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/src/android/nearby/fastpair/seeker/data/FastPairTestDataManager.kt b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/src/android/nearby/fastpair/seeker/data/FastPairTestDataManager.kt
new file mode 100644
index 0000000..ffc02a0
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/src/android/nearby/fastpair/seeker/data/FastPairTestDataManager.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.fastpair.seeker.data
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.nearby.FastPairAccountKeyDeviceMetadata
+import android.nearby.fastpair.seeker.*
+import android.util.Log
+
+/** Manage local FastPairTestDataCache and receive/update the remote cache in test snippet. */
+class FastPairTestDataManager(private val context: Context) : BroadcastReceiver() {
+    val testDataCache = FastPairTestDataCache()
+
+    /** Writes a FastPairAccountKeyDeviceMetadata into local and remote cache.
+     *
+     * @param accountKeyDeviceMetadata the FastPairAccountKeyDeviceMetadata to write.
+     */
+    fun writeAccountKeyDeviceMetadata(accountKeyDeviceMetadata: FastPairAccountKeyDeviceMetadata) {
+        testDataCache.putAccountKeyDeviceMetadata(accountKeyDeviceMetadata)
+
+        val json =
+            testDataCache.dumpAccountKeyDeviceMetadataAsJson(accountKeyDeviceMetadata)
+        Intent().also { intent ->
+            intent.action = ACTION_WRITE_ACCOUNT_KEY_DEVICE_METADATA
+            intent.putExtra(DATA_JSON_STRING_KEY, json)
+            context.sendBroadcast(intent)
+        }
+    }
+
+    /**
+     * Callback method for receiving Intent broadcast from test snippet.
+     *
+     * See [BroadcastReceiver#onReceive].
+     *
+     * @param context the Context in which the receiver is running.
+     * @param intent the Intent being received.
+     */
+    override fun onReceive(context: Context, intent: Intent) {
+        when (intent.action) {
+            ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA -> {
+                Log.d(TAG, "ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA received!")
+                val modelId = intent.getStringExtra(DATA_MODEL_ID_STRING_KEY)!!
+                val json = intent.getStringExtra(DATA_JSON_STRING_KEY)!!
+                testDataCache.putAntispoofKeyDeviceMetadata(modelId, json)
+            }
+            ACTION_SEND_ACCOUNT_KEY_DEVICE_METADATA -> {
+                Log.d(TAG, "ACTION_SEND_ACCOUNT_KEY_DEVICE_METADATA received!")
+                val json = intent.getStringExtra(DATA_JSON_STRING_KEY)!!
+                testDataCache.putAccountKeyDeviceMetadata(json)
+            }
+            ACTION_RESET_TEST_DATA_CACHE -> {
+                Log.d(TAG, "ACTION_RESET_TEST_DATA_CACHE received!")
+                testDataCache.reset()
+            }
+            else -> Log.d(TAG, "Unknown action received!")
+        }
+    }
+
+    companion object {
+        private const val TAG = "FastPairTestDataManager"
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/src/android/nearby/fastpair/seeker/dataprovider/FastPairTestDataProviderService.kt b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/src/android/nearby/fastpair/seeker/dataprovider/FastPairTestDataProviderService.kt
new file mode 100644
index 0000000..cec0607
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_service/fastpair_seeker_data_provider/src/android/nearby/fastpair/seeker/dataprovider/FastPairTestDataProviderService.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.fastpair.seeker.dataprovider
+
+import android.accounts.Account
+import android.content.IntentFilter
+import android.nearby.FastPairDataProviderService
+import android.nearby.FastPairEligibleAccount
+import android.nearby.fastpair.seeker.*
+import android.nearby.fastpair.seeker.data.FastPairTestDataManager
+import android.util.Log
+
+/**
+ * Fast Pair Test Data Provider Service entry point for platform overlay.
+ */
+class FastPairTestDataProviderService : FastPairDataProviderService(TAG) {
+    private lateinit var testDataManager: FastPairTestDataManager
+
+    override fun onCreate() {
+        Log.d(TAG, "onCreate()")
+        testDataManager = FastPairTestDataManager(this)
+
+        val bondStateFilter = IntentFilter(ACTION_RESET_TEST_DATA_CACHE).apply {
+            addAction(ACTION_SEND_ACCOUNT_KEY_DEVICE_METADATA)
+            addAction(ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA)
+        }
+        registerReceiver(testDataManager, bondStateFilter)
+    }
+
+    override fun onDestroy() {
+        Log.d(TAG, "onDestroy()")
+        unregisterReceiver(testDataManager)
+
+        super.onDestroy()
+    }
+
+    override fun onLoadFastPairAntispoofKeyDeviceMetadata(
+        request: FastPairAntispoofKeyDeviceMetadataRequest,
+        callback: FastPairAntispoofKeyDeviceMetadataCallback
+    ) {
+        val requestedModelId = request.modelId.bytesToStringLowerCase()
+        Log.d(TAG, "onLoadFastPairAntispoofKeyDeviceMetadata(modelId: $requestedModelId)")
+
+        val fastPairAntispoofKeyDeviceMetadata =
+            testDataManager.testDataCache.getAntispoofKeyDeviceMetadata(requestedModelId)
+        if (fastPairAntispoofKeyDeviceMetadata != null) {
+            callback.onFastPairAntispoofKeyDeviceMetadataReceived(fastPairAntispoofKeyDeviceMetadata)
+        } else {
+            Log.d(TAG, "No metadata available for $requestedModelId!")
+            callback.onError(ERROR_CODE_BAD_REQUEST, "No metadata available for $requestedModelId")
+        }
+    }
+
+    override fun onLoadFastPairAccountDevicesMetadata(
+        request: FastPairAccountDevicesMetadataRequest,
+        callback: FastPairAccountDevicesMetadataCallback
+    ) {
+        val requestedAccount = request.account
+        val requestedAccountKeys = request.deviceAccountKeys
+        Log.d(
+            TAG, "onLoadFastPairAccountDevicesMetadata(" +
+                    "account: $requestedAccount, accountKeys:$requestedAccountKeys)"
+        )
+        Log.d(TAG, testDataManager.testDataCache.dumpAccountKeyDeviceMetadataListAsJson())
+
+        callback.onFastPairAccountDevicesMetadataReceived(
+            testDataManager.testDataCache.getAccountKeyDeviceMetadataList()
+        )
+    }
+
+    override fun onLoadFastPairEligibleAccounts(
+        request: FastPairEligibleAccountsRequest,
+        callback: FastPairEligibleAccountsCallback
+    ) {
+        Log.d(TAG, "onLoadFastPairEligibleAccounts()")
+        callback.onFastPairEligibleAccountsReceived(ELIGIBLE_ACCOUNTS_TEST_CONSTANT)
+    }
+
+    override fun onManageFastPairAccount(
+        request: FastPairManageAccountRequest, callback: FastPairManageActionCallback
+    ) {
+        val requestedAccount = request.account
+        val requestType = request.requestType
+        Log.d(TAG, "onManageFastPairAccount(account: $requestedAccount, requestType: $requestType)")
+
+        callback.onSuccess()
+    }
+
+    override fun onManageFastPairAccountDevice(
+        request: FastPairManageAccountDeviceRequest, callback: FastPairManageActionCallback
+    ) {
+        val requestedAccount = request.account
+        val requestType = request.requestType
+        val requestTypeString = if (requestType == MANAGE_REQUEST_ADD) "Add" else "Remove"
+        val requestedBleAddress = request.bleAddress
+        val requestedAccountKeyDeviceMetadata = request.accountKeyDeviceMetadata
+        Log.d(
+            TAG,
+            "onManageFastPairAccountDevice(requestedAccount: $requestedAccount, " +
+                    "requestType: $requestTypeString,"
+        )
+        Log.d(TAG, "requestedBleAddress: $requestedBleAddress,")
+        Log.d(TAG, "requestedAccountKeyDeviceMetadata: $requestedAccountKeyDeviceMetadata)")
+
+        testDataManager.writeAccountKeyDeviceMetadata(requestedAccountKeyDeviceMetadata)
+
+        callback.onSuccess()
+    }
+
+    companion object {
+        private const val TAG = "FastPairTestDataProviderService"
+        private val ELIGIBLE_ACCOUNTS_TEST_CONSTANT = listOf(
+            FastPairEligibleAccount.Builder()
+                .setAccount(Account(FAKE_TEST_ACCOUNT_NAME, "FakeTestAccount"))
+                .setOptIn(true)
+                .build(),
+        )
+
+        private fun ByteArray.bytesToStringLowerCase(): String =
+            joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/Android.bp b/nearby/tests/multidevices/clients/test_support/fastpair_provider/Android.bp
new file mode 100644
index 0000000..920834a
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/Android.bp
@@ -0,0 +1,35 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+    name: "NearbyFastPairProviderLib",
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
+    sdk_version: "test_current",
+    static_libs: [
+        "NearbyFastPairProviderLiteProtos",
+        "androidx.test.core",
+        "error_prone_annotations",
+        "fast-pair-lite-protos",
+        "framework-annotations-lib",
+        "kotlin-stdlib",
+        "service-nearby-pre-jarjar",
+    ],
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/AndroidManifest.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/AndroidManifest.xml
new file mode 100644
index 0000000..400a434
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/AndroidManifest.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.nearby.fastpair.provider">
+
+    <uses-feature android:name="android.hardware.bluetooth" />
+    <uses-feature android:name="android.hardware.bluetooth_le" />
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+    <uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED" />
+    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.GET_ACCOUNTS"/>
+
+</manifest>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/Android.bp b/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/Android.bp
new file mode 100644
index 0000000..7ae43e5
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/Android.bp
@@ -0,0 +1,30 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+    name: "NearbyFastPairProviderLiteProtos",
+    proto: {
+        type: "lite",
+        canonical_path_from_root: false,
+    },
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+    srcs: ["*.proto"],
+}
+
+
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/event_stream_protocol.proto b/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/event_stream_protocol.proto
new file mode 100644
index 0000000..54db34a
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/event_stream_protocol.proto
@@ -0,0 +1,85 @@
+syntax = "proto2";
+
+package android.nearby.fastpair.provider;
+
+option java_package = "android.nearby.fastpair.provider";
+option java_outer_classname = "EventStreamProtocol";
+
+enum EventGroup {
+  UNSPECIFIED = 0;
+  BLUETOOTH = 1;
+  LOGGING = 2;
+  DEVICE = 3;
+  DEVICE_ACTION = 4;
+  DEVICE_CONFIGURATION = 5;
+  DEVICE_CAPABILITY_SYNC = 6;
+  SMART_AUDIO_SOURCE_SWITCHING = 7;
+  ACKNOWLEDGEMENT = 255;
+}
+
+enum BluetoothEventCode {
+  BLUETOOTH_UNSPECIFIED = 0;
+  BLUETOOTH_ENABLE_SILENCE_MODE = 1;
+  BLUETOOTH_DISABLE_SILENCE_MODE = 2;
+}
+
+enum LoggingEventCode {
+  LOG_UNSPECIFIED = 0;
+  LOG_FULL = 1;
+  LOG_SAVE_TO_BUFFER = 2;
+}
+
+enum DeviceEventCode {
+  DEVICE_UNSPECIFIED = 0;
+  DEVICE_MODEL_ID = 1;
+  DEVICE_BLE_ADDRESS = 2;
+  DEVICE_BATTERY_INFO = 3;
+  ACTIVE_COMPONENTS_REQUEST = 5;
+  ACTIVE_COMPONENTS_RESPONSE = 6;
+  DEVICE_CAPABILITY = 7;
+  PLATFORM_TYPE = 8;
+  FIRMWARE_VERSION = 9;
+  SECTION_NONCE = 10;
+}
+
+enum DeviceActionEventCode {
+  DEVICE_ACTION_UNSPECIFIED = 0;
+  DEVICE_ACTION_RING = 1;
+}
+
+enum DeviceConfigurationEventCode {
+  CONFIGURATION_UNSPECIFIED = 0;
+  CONFIGURATION_BUFFER_SIZE = 1;
+}
+
+enum DeviceCapabilitySyncEventCode {
+  REQUEST_UNSPECIFIED = 0;
+  REQUEST_CAPABILITY_UPDATE = 1;
+  CONFIGURABLE_BUFFER_SIZE_RANGE = 2;
+}
+
+enum AcknowledgementEventCode {
+  ACKNOWLEDGEMENT_UNSPECIFIED = 0;
+  ACKNOWLEDGEMENT_ACK = 1;
+  ACKNOWLEDGEMENT_NAK = 2;
+}
+
+enum PlatformType {
+  PLATFORM_TYPE_UNKNOWN = 0;
+  ANDROID = 1;
+}
+
+enum SassEventCode {
+  EVENT_UNSPECIFIED = 0;
+  EVENT_GET_CAPABILITY_OF_SASS = 0x10;
+  EVENT_NOTIFY_CAPABILITY_OF_SASS = 0x11;
+  EVENT_SET_MULTI_POINT_STATE = 0x12;
+  EVENT_SWITCH_AUDIO_SOURCE_BETWEEN_CONNECTED_DEVICES = 0x30;
+  EVENT_SWITCH_BACK = 0x31;
+  EVENT_NOTIFY_MULTIPOINT_SWITCH_EVENT = 0x32;
+  EVENT_GET_CONNECTION_STATUS = 0x33;
+  EVENT_NOTIFY_CONNECTION_STATUS = 0x34;
+  EVENT_SASS_INITIATED_CONNECTION = 0x40;
+  EVENT_INDICATE_IN_USE_ACCOUNT_KEY = 0x41;
+  EVENT_SET_CUSTOM_DATA = 0x42;
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/Android.bp b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/Android.bp
new file mode 100644
index 0000000..f3eed51
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/Android.bp
@@ -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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+    name: "NearbyFastPairProviderSimulatorApp",
+    sdk_version: "test_current",
+    static_libs: ["NearbyFastPairProviderSimulatorLib"],
+    optimize: {
+        enabled: true,
+        shrink: true,
+        proguard_flags_files: ["proguard.flags"],
+    },
+}
+
+android_library {
+    name: "NearbyFastPairProviderSimulatorLib",
+    sdk_version: "test_current",
+        srcs: [
+            "src/**/*.java",
+            "src/**/*.kt",
+        ],
+        static_libs: [
+            "NearbyFastPairProviderLib",
+            "NearbyFastPairProviderLiteProtos",
+            "NearbyFastPairProviderSimulatorLiteProtos",
+            "androidx.annotation_annotation",
+            "error_prone_annotations",
+            "fast-pair-lite-protos",
+        ],
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/AndroidManifest.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/AndroidManifest.xml
new file mode 100644
index 0000000..8880b11
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/AndroidManifest.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.nearby.fastpair.provider.simulator.app" >
+
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.GET_ACCOUNTS"/>
+
+    <application
+        android:allowBackup="true"
+        android:label="@string/app_name" >
+        <activity
+            android:name=".MainActivity"
+            android:windowSoftInputMode="stateHidden"
+            android:screenOrientation="portrait"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT"/>
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proguard.flags b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proguard.flags
new file mode 100644
index 0000000..28680b3
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proguard.flags
@@ -0,0 +1,19 @@
+# Keep simulator reflection callback.
+-keep class android.nearby.fastpair.provider.** {
+     *;
+}
+
+# Keep names for easy debugging.
+-dontobfuscate
+
+# Necessary to allow debugging.
+-keepattributes *
+
+# By default, proguard leaves all classes in their original package, which
+# needlessly repeats com.google.android.apps.etc.
+-repackageclasses ""
+
+# Allows proguard to make private and protected methods and fields public as
+# part of optimization. This lets proguard inline trivial getter/setter
+# methods.
+-allowaccessmodification
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/Android.bp b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/Android.bp
new file mode 100644
index 0000000..e964800
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/Android.bp
@@ -0,0 +1,30 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+    name: "NearbyFastPairProviderSimulatorLiteProtos",
+    proto: {
+        type: "lite",
+        canonical_path_from_root: false,
+    },
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+    srcs: ["*.proto"],
+}
+
+
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/simulator_stream_protocol.proto b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/simulator_stream_protocol.proto
new file mode 100644
index 0000000..9b17fda
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/simulator_stream_protocol.proto
@@ -0,0 +1,110 @@
+syntax = "proto2";
+
+package android.nearby.fastpair.provider.simulator;
+
+option java_package = "android.nearby.fastpair.provider.simulator";
+option java_outer_classname = "SimulatorStreamProtocol";
+
+// Used by remote devices to control simulator behaviors.
+message Command {
+  // Type of this command.
+  required Code code = 1;
+
+  // Required for SHOW_BATTERY.
+  optional BatteryInfo battery_info = 2;
+
+  enum Code {
+    // Request for simulator's acknowledge message.
+    POLLING = 0;
+
+    // Reset and clear bluetooth state.
+    RESET = 1;
+
+    // Present battery information in the advertisement.
+    SHOW_BATTERY = 2;
+
+    // Remove battery information in the advertisement.
+    HIDE_BATTERY = 3;
+
+    // Request for BR/EDR address.
+    REQUEST_BLUETOOTH_ADDRESS_PUBLIC = 4;
+
+    // Request for BLE address.
+    REQUEST_BLUETOOTH_ADDRESS_BLE = 5;
+
+    // Request for account key.
+    REQUEST_ACCOUNT_KEY = 6;
+  }
+
+  // Battery information for true wireless headsets.
+  // https://devsite.googleplex.com/nearby/fast-pair/early-access/spec#BatteryNotification
+  message BatteryInfo {
+    // Show or hide the battery UI notification.
+    optional bool suppress_notification = 1;
+    repeated BatteryValue battery_values = 2;
+
+    // Advertised battery level data.
+    message BatteryValue {
+      // The charging flag.
+      required bool charging = 1;
+
+      // Battery level from 0 to 100.
+      required uint32 level = 2;
+    }
+  }
+}
+
+// Notify the remote devices when states are changed or response the command on
+// the simulator.
+message Event {
+  // Type of this event.
+  required Code code = 1;
+
+  // Required for BLUETOOTH_STATE_BOND.
+  optional int32 bond_state = 2;
+
+  // Required for BLUETOOTH_STATE_CONNECTION.
+  optional int32 connection_state = 3;
+
+  // Required for BLUETOOTH_STATE_SCAN_MODE.
+  optional int32 scan_mode = 4;
+
+  // Required for BLUETOOTH_ADDRESS_PUBLIC.
+  optional string public_address = 5;
+
+  // Required for BLUETOOTH_ADDRESS_BLE.
+  optional string ble_address = 6;
+
+  // Required for BLUETOOTH_ALIAS_NAME.
+  optional string alias_name = 7;
+
+  // Required for REQUEST_ACCOUNT_KEY.
+  optional bytes account_key = 8;
+
+  enum Code {
+    // Response the polling.
+    ACKNOWLEDGE = 0;
+
+    // Notify the event android.bluetooth.device.action.BOND_STATE_CHANGED
+    BLUETOOTH_STATE_BOND = 1;
+
+    // Notify the event
+    // android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED
+    BLUETOOTH_STATE_CONNECTION = 2;
+
+    // Notify the event android.bluetooth.adapter.action.SCAN_MODE_CHANGED
+    BLUETOOTH_STATE_SCAN_MODE = 3;
+
+    // Notify the current BR/EDR address
+    BLUETOOTH_ADDRESS_PUBLIC = 4;
+
+    // Notify the current BLE address
+    BLUETOOTH_ADDRESS_BLE = 5;
+
+    // Notify the event android.bluetooth.device.action.ALIAS_CHANGED
+    BLUETOOTH_ALIAS_NAME = 6;
+
+    // Response the REQUEST_ACCOUNT_KEY.
+    ACCOUNT_KEY = 7;
+  }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/activity_main.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/activity_main.xml
new file mode 100644
index 0000000..b7e85eb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/activity_main.xml
@@ -0,0 +1,190 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:layout_margin="16dp"
+    android:keepScreenOn="true"
+    tools:context=".MainActivity">
+
+    <TextView
+        android:id="@+id/bluetooth_address_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <TextView
+        android:id="@+id/device_name_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="8dp"
+        android:orientation="horizontal">
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textSize="14dp"
+                android:textStyle="bold"
+                android:text="Model ID:"/>
+            <Spinner
+                android:id="@+id/model_id_spinner"
+                android:textSize="14dp"
+                android:textStyle="bold"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1" />
+        <TextView
+            android:id="@+id/tx_power_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold" />
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/anti_spoofing_private_key_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+        <TextView
+            android:id="@+id/is_advertising_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+        <TextView
+            android:id="@+id/scan_mode_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/remote_device_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+        <TextView
+            android:id="@+id/is_paired_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+        <TextView
+            android:id="@+id/is_connected_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+    </LinearLayout>
+
+    <Button
+        android:id="@+id/reset_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="Reset"
+        android:onClick="onResetButtonClicked"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:layout_marginBottom="8dp"
+        android:orientation="horizontal"
+        android:layout_gravity="center_vertical">
+
+      <Spinner
+          android:id="@+id/event_stream_spinner"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"/>
+
+      <Button
+          android:id="@+id/send_event_message_button"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          android:text="Send Event Message"
+          android:onClick="onSendEventStreamMessageButtonClicked"/>
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="8dp"
+        android:orientation="horizontal"
+        android:layout_gravity="center_vertical">
+        <Switch
+            android:id="@+id/fail_switch"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Force Fail" />
+        <Switch
+            android:id="@+id/app_launch_switch"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Trigger app launch"
+            android:paddingLeft="8dp"/>
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@+id/adv_options"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:layout_marginBottom="8dp"
+        android:orientation="horizontal"
+        android:layout_gravity="center_vertical">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginRight="8dp"
+            android:textColor="@android:color/black"
+            android:text="adv options"/>
+
+        <Spinner
+            android:id="@+id/adv_option_spinner"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"/>
+
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:gravity="bottom"
+        android:scrollbars="vertical"/>
+</LinearLayout>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/user_input_dialog.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/user_input_dialog.xml
new file mode 100644
index 0000000..980b057
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/user_input_dialog.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:padding="16dp">
+
+  <EditText
+      android:id="@+id/userInputDialog"
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:hint="@string/firmware_input_hint"
+      android:inputType="text" />
+
+</LinearLayout>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/menu/menu.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/menu/menu.xml
new file mode 100644
index 0000000..f225522
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/menu/menu.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:context=".MainActivity">
+
+  <item
+      android:id="@+id/sign_out_menu_item"
+      android:title="Sign out"/>
+  <item
+      android:id="@+id/reset_account_keys_menu_item"
+      android:title="Reset Account Keys"/>
+  <item
+      android:id="@+id/reset_device_name_menu_item"
+      android:title="Reset Device Name"/>
+  <item
+    android:id="@+id/set_firmware_version"
+    android:title="Set Firmware Version"/>
+  <item
+      android:id="@+id/set_simulator_capability"
+      android:title="Set Simulator Capability"/>
+  <item
+    android:id="@+id/use_new_gatt_characteristics_id"
+    android:checkable="true"
+    android:checked="false"
+    android:title="Use new GATT characteristics id"/>
+</menu>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/dimens.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/dimens.xml
new file mode 100644
index 0000000..47c8224
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/dimens.xml
@@ -0,0 +1,5 @@
+<resources>
+    <!-- Default screen margins, per the Android Design guidelines. -->
+    <dimen name="activity_horizontal_margin">16dp</dimen>
+    <dimen name="activity_vertical_margin">16dp</dimen>
+</resources>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/strings.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/strings.xml
new file mode 100644
index 0000000..5123038
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/strings.xml
@@ -0,0 +1,31 @@
+<resources>
+    <string name="app_name">Fast Pair Provider Simulator</string>
+    <string-array name="adv_options">
+        <item>0: No battery info</item>
+        <item>1: Show L(⬆) + R(⬆) + C(⬆)</item>
+        <item>2: Show L + R + C(unknown)</item>
+        <item>3: Show L(low 10) + R(low 9) + C(low 25)</item>
+        <item>4: Suppress battery w/o level changes</item>
+        <item>5: Suppress L(low 10) + R(11) + C</item>
+        <item>6: Suppress L(low ⬆) + R(low ⬆) + C(low 10)</item>
+        <item>7: Suppress L(low ⬆) + R(low ⬆) + C(low ⬆)</item>
+        <item>8: Show subsequent pairing notification</item>
+        <item>9: Suppress subsequent pairing notification</item>
+    </string-array>
+    <string-array name="event_stream_options">
+        <item>OHD event</item>
+        <item>Log event</item>
+        <item>Battery event</item>
+    </string-array>
+    <string name="firmware_dialog_title">Firmware version number</string>
+    <string name="firmware_input_hint">Type in version number</string>
+    <string name="passkey_dialog_title">Passkey needed</string>
+    <string name="passkey_input_hint">Type in passkey</string>
+    <!-- Passkey confirmation dialog title. [CHAR_LIMIT=NONE]-->
+    <string name="confirm_passkey">Confirm passkey</string>
+    <string name="model_id_progress_title">Get models from server</string>
+
+    <!-- Fast Pair Simulator: pair one device only. -->
+    <string name="fast_pair_simulator" translatable="false">Fast Pair Simulator</string>
+
+</resources>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/FutureCallbackWrapper.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/FutureCallbackWrapper.java
new file mode 100644
index 0000000..4db8560
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/FutureCallbackWrapper.java
@@ -0,0 +1,69 @@
+/*
+ * 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.fastpair.provider.simulator.app;
+
+import android.util.Log;
+
+import com.google.common.util.concurrent.FutureCallback;
+
+/** Wrapper for {@link FutureCallback} to prevent the memory linkage. */
+public abstract class FutureCallbackWrapper<T> implements FutureCallback<T> {
+    private static final String TAG = FutureCallback.class.getSimpleName();
+
+    public static FutureCallbackWrapper<Void> createRegisterCallback(MainActivity activity) {
+        String id = activity.mRemoteDeviceId;
+        return new FutureCallbackWrapper<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+                Log.d(TAG, String.format("%s was registered", id));
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, String.format("Failed to register %s", id), t);
+            }
+        };
+    }
+
+    public static FutureCallbackWrapper<Void> createDefaultIOCallback(MainActivity activity) {
+        String id = activity.mRemoteDeviceId;
+        return new FutureCallbackWrapper<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, String.format("IO stream error on %s", id), t);
+            }
+        };
+    }
+
+    public static FutureCallbackWrapper<Void> createDestroyCallback() {
+        return new FutureCallbackWrapper<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+                Log.d(TAG, "remote devices manager is destroyed");
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, "Failed to destroy remote devices manager", t);
+            }
+        };
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/MainActivity.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/MainActivity.java
new file mode 100644
index 0000000..e916c53
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/MainActivity.java
@@ -0,0 +1,1044 @@
+/*
+ * 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.fastpair.provider.simulator.app;
+
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_STATE_BOND;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_STATE_CONNECTION;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_STATE_SCAN_MODE;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.io.BaseEncoding.base64;
+
+import android.Manifest.permission;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.AdvertiseSettings;
+import android.content.DialogInterface;
+import android.content.SharedPreferences;
+import android.graphics.Color;
+import android.nearby.fastpair.provider.EventStreamProtocol.EventGroup;
+import android.nearby.fastpair.provider.FastPairSimulator;
+import android.nearby.fastpair.provider.FastPairSimulator.BatteryValue;
+import android.nearby.fastpair.provider.FastPairSimulator.KeyInputCallback;
+import android.nearby.fastpair.provider.FastPairSimulator.PasskeyEventCallback;
+import android.nearby.fastpair.provider.bluetooth.BluetoothController;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event;
+import android.nearby.fastpair.provider.simulator.testing.RemoteDevice;
+import android.nearby.fastpair.provider.simulator.testing.RemoteDevicesManager;
+import android.nearby.fastpair.provider.simulator.testing.StreamIOHandlerFactory;
+import android.nearby.fastpair.provider.utils.Logger;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.method.ScrollingMovementMethod;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.Switch;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.util.Consumer;
+
+import com.google.common.base.Ascii;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.protobuf.ByteString;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.Executors;
+
+import service.proto.Rpcs.AntiSpoofingKeyPair;
+import service.proto.Rpcs.Device;
+import service.proto.Rpcs.DeviceType;
+
+/**
+ * Simulates a Fast Pair device (e.g. a headset).
+ *
+ * <p>See README in this directory, and {http://go/fast-pair-spec}.
+ */
+@SuppressLint("SetTextI18n")
+public class MainActivity extends Activity {
+    public static final String TAG = "FastPairProviderSimulatorApp";
+    private final Logger mLogger = new Logger(TAG);
+
+    /** Device has a display and the ability to input Yes/No. */
+    private static final int IO_CAPABILITY_IO = 1;
+
+    /** Device only has a keyboard for entry but no display. */
+    private static final int IO_CAPABILITY_IN = 2;
+
+    /** Device has no Input or Output capability. */
+    private static final int IO_CAPABILITY_NONE = 3;
+
+    /** Device has a display and a full keyboard. */
+    private static final int IO_CAPABILITY_KBDISP = 4;
+
+    private static final String SHARED_PREFS_NAME =
+            "android.nearby.fastpair.provider.simulator.app";
+    private static final String EXTRA_MODEL_ID = "MODEL_ID";
+    private static final String EXTRA_BLUETOOTH_ADDRESS = "BLUETOOTH_ADDRESS";
+    private static final String EXTRA_TX_POWER_LEVEL = "TX_POWER_LEVEL";
+    private static final String EXTRA_FIRMWARE_VERSION = "FIRMWARE_VERSION";
+    private static final String EXTRA_SUPPORT_DYNAMIC_SIZE = "SUPPORT_DYNAMIC_SIZE";
+    private static final String EXTRA_USE_RANDOM_SALT_FOR_ACCOUNT_KEY_ROTATION =
+            "USE_RANDOM_SALT_FOR_ACCOUNT_KEY_ROTATION";
+    private static final String EXTRA_REMOTE_DEVICE_ID = "REMOTE_DEVICE_ID";
+    private static final String EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID =
+            "USE_NEW_GATT_CHARACTERISTICS_ID";
+    public static final String EXTRA_REMOVE_ALL_DEVICES_DURING_PAIRING =
+            "REMOVE_ALL_DEVICES_DURING_PAIRING";
+    private static final String KEY_ACCOUNT_NAME = "ACCOUNT_NAME";
+    private static final String[] PERMISSIONS =
+            new String[]{permission.BLUETOOTH, permission.BLUETOOTH_ADMIN, permission.GET_ACCOUNTS};
+    private static final int LIGHT_GREEN = 0xFFC8FFC8;
+    private static final String ANTI_SPOOFING_KEY_LABEL = "Anti-spoofing key";
+
+    private static final ImmutableMap<String, String> ANTI_SPOOFING_PRIVATE_KEY_MAP =
+            new ImmutableMap.Builder<String, String>()
+                    .put("361A2E", "/1rMqyJRGeOK6vkTNgM70xrytxdKg14mNQkITeusK20=")
+                    .put("00000D", "03/MAmUPTGNsN+2iA/1xASXoPplDh3Ha5/lk2JgEBx4=")
+                    .put("00000C", "Cbj9eCJrTdDgSYxLkqtfADQi86vIaMvxJsQ298sZYWE=")
+                    // BLE only devices
+                    .put("49426D", "I5QFOJW0WWFgKKZiwGchuseXsq/p9RN/aYtNsGEVGT0=")
+                    .put("01E5CE", "FbHt8STpHJDd4zFQFjimh4Zt7IU94U28MOEIXgUEeCw=")
+                    .put("8D13B9", "mv++LcJB1n0mbLNGWlXCv/8Gb6aldctrJC4/Ma/Q3Rg=")
+                    .put("9AB0F6", "9eKQNwJUr5vCg0c8rtOXkJcWTAsBmmvEKSgXIqAd50Q=")
+                    // Android Auto
+                    .put("8E083D", "hGQeREDKM/H1834zWMmTIe0Ap4Zl5igThgE62OtdcKA=")
+                    .buildOrThrow();
+
+    private static final Uri REMOTE_DEVICE_INPUT_STREAM_URI =
+            Uri.fromFile(new File("/data/local/nearby/tmp/read.pipe"));
+
+    private static final Uri REMOTE_DEVICE_OUTPUT_STREAM_URI =
+            Uri.fromFile(new File("/data/local/nearby/tmp/write.pipe"));
+
+    private static final String MODEL_ID_DEFAULT = "00000C";
+
+    private static final String MODEL_ID_APP_LAUNCH = "60EB56";
+
+    private static final int MODEL_ID_LENGTH = 6;
+
+    private BluetoothController mBluetoothController;
+    private final BluetoothController.EventListener mEventListener =
+            new BluetoothController.EventListener() {
+
+                @Override
+                public void onBondStateChanged(int bondState) {
+                    sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(BLUETOOTH_STATE_BOND).setBondState(
+                                    bondState));
+                    updateStatusView();
+                }
+
+                @Override
+                public void onConnectionStateChanged(int connectionState) {
+                    sendEventToRemoteDevice(
+                            Event.newBuilder()
+                                    .setCode(BLUETOOTH_STATE_CONNECTION)
+                                    .setConnectionState(connectionState));
+                    updateStatusView();
+                }
+
+                @Override
+                public void onScanModeChange(int mode) {
+                    sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(BLUETOOTH_STATE_SCAN_MODE).setScanMode(
+                                    mode));
+                    updateStatusView();
+                }
+
+                @Override
+                public void onA2DPSinkProfileConnected() {
+                    reset();
+                }
+            };
+
+    @Nullable
+    private FastPairSimulator mFastPairSimulator;
+    @Nullable
+    private AlertDialog mInputPasskeyDialog;
+    private Switch mFailSwitch;
+    private Switch mAppLaunchSwitch;
+    private Spinner mAdvOptionSpinner;
+    private Spinner mEventStreamSpinner;
+    private EventGroup mEventGroup;
+    private SharedPreferences mSharedPreferences;
+    private Spinner mModelIdSpinner;
+    private final RemoteDevicesManager mRemoteDevicesManager = new RemoteDevicesManager();
+    @Nullable
+    private RemoteDeviceListener mInputStreamListener;
+    @Nullable
+    String mRemoteDeviceId;
+    private final Map<String, Device> mModelsMap = new LinkedHashMap<>();
+    private boolean mRemoveAllDevicesDuringPairing = true;
+
+    void sendEventToRemoteDevice(Event.Builder eventBuilder) {
+        if (mRemoteDeviceId == null) {
+            return;
+        }
+
+        mLogger.log("Send data to output stream: %s", eventBuilder.getCode().getNumber());
+        mRemoteDevicesManager.writeDataToRemoteDevice(
+                mRemoteDeviceId,
+                eventBuilder.build().toByteString(),
+                FutureCallbackWrapper.createDefaultIOCallback(this));
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        setContentView(R.layout.activity_main);
+
+        mSharedPreferences = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
+
+        mRemoveAllDevicesDuringPairing =
+                getIntent().getBooleanExtra(EXTRA_REMOVE_ALL_DEVICES_DURING_PAIRING, true);
+
+        mFailSwitch = findViewById(R.id.fail_switch);
+        mFailSwitch.setOnCheckedChangeListener((CompoundButton buttonView, boolean isChecked) -> {
+            if (mFastPairSimulator != null) {
+                mFastPairSimulator.setShouldFailPairing(isChecked);
+            }
+        });
+
+        mAppLaunchSwitch = findViewById(R.id.app_launch_switch);
+        mAppLaunchSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> reset());
+
+        mAdvOptionSpinner = findViewById(R.id.adv_option_spinner);
+        mEventStreamSpinner = findViewById(R.id.event_stream_spinner);
+        ArrayAdapter<CharSequence> advOptionAdapter =
+                ArrayAdapter.createFromResource(
+                        this, R.array.adv_options, android.R.layout.simple_spinner_item);
+        ArrayAdapter<CharSequence> eventStreamAdapter =
+                ArrayAdapter.createFromResource(
+                        this, R.array.event_stream_options, android.R.layout.simple_spinner_item);
+        advOptionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        mAdvOptionSpinner.setAdapter(advOptionAdapter);
+        mEventStreamSpinner.setAdapter(eventStreamAdapter);
+        mAdvOptionSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> adapterView, View view, int position,
+                    long id) {
+                startAdvertisingBatteryInformationBasedOnOption(position);
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> adapterView) {
+            }
+        });
+        mEventStreamSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position,
+                    long id) {
+                switch (EventGroup.forNumber(position + 1)) {
+                    case BLUETOOTH:
+                        mEventGroup = EventGroup.BLUETOOTH;
+                        break;
+                    case LOGGING:
+                        mEventGroup = EventGroup.LOGGING;
+                        break;
+                    case DEVICE:
+                        mEventGroup = EventGroup.DEVICE;
+                        break;
+                    default:
+                        // fall through
+                }
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> parent) {
+            }
+        });
+        setupModelIdSpinner();
+        setupRemoteDevices();
+        if (checkPermissions(PERMISSIONS)) {
+            mBluetoothController = new BluetoothController(this, mEventListener);
+            mBluetoothController.registerBluetoothStateReceiver();
+            mBluetoothController.enableBluetooth();
+            mBluetoothController.connectA2DPSinkProfile();
+
+            if (mSharedPreferences.getString(KEY_ACCOUNT_NAME, "").isEmpty()) {
+                putFixedModelLocal();
+                resetModelIdSpinner();
+                reset();
+            }
+        } else {
+            requestPermissions(PERMISSIONS, 0 /* requestCode */);
+        }
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.menu, menu);
+        menu.findItem(R.id.use_new_gatt_characteristics_id).setChecked(
+                getFromIntentOrPrefs(
+                        EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID, /* defaultValue= */ false));
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == R.id.sign_out_menu_item) {
+            recreate();
+            return true;
+        } else if (item.getItemId() == R.id.reset_account_keys_menu_item) {
+            resetAccountKeys();
+            return true;
+        } else if (item.getItemId() == R.id.reset_device_name_menu_item) {
+            resetDeviceName();
+            return true;
+        } else if (item.getItemId() == R.id.set_firmware_version) {
+            setFirmware();
+            return true;
+        } else if (item.getItemId() == R.id.set_simulator_capability) {
+            setSimulatorCapability();
+            return true;
+        } else if (item.getItemId() == R.id.use_new_gatt_characteristics_id) {
+            if (!item.isChecked()) {
+                item.setChecked(true);
+                mSharedPreferences.edit()
+                        .putBoolean(EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID, true).apply();
+            } else {
+                item.setChecked(false);
+                mSharedPreferences.edit()
+                        .putBoolean(EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID, false).apply();
+            }
+            reset();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void setFirmware() {
+        View firmwareInputView =
+                LayoutInflater.from(getApplicationContext()).inflate(R.layout.user_input_dialog,
+                        null);
+        EditText userInputDialogEditText = firmwareInputView.findViewById(R.id.userInputDialog);
+        new AlertDialog.Builder(MainActivity.this)
+                .setView(firmwareInputView)
+                .setCancelable(false)
+                .setPositiveButton(android.R.string.ok, (dialogBox, id) -> {
+                    String input = userInputDialogEditText.getText().toString();
+                    mSharedPreferences.edit().putString(EXTRA_FIRMWARE_VERSION,
+                            input).apply();
+                    reset();
+                })
+                .setNegativeButton(android.R.string.cancel, null)
+                .setTitle(R.string.firmware_dialog_title)
+                .show();
+    }
+
+    private void setSimulatorCapability() {
+        String[] capabilityKeys = new String[]{EXTRA_SUPPORT_DYNAMIC_SIZE};
+        String[] capabilityNames = new String[]{"Dynamic Buffer Size"};
+        // Default values.
+        boolean[] capabilitySelected = new boolean[]{false};
+        // Get from preferences if exist.
+        for (int i = 0; i < capabilityKeys.length; i++) {
+            capabilitySelected[i] =
+                    mSharedPreferences.getBoolean(capabilityKeys[i], capabilitySelected[i]);
+        }
+
+        new AlertDialog.Builder(MainActivity.this)
+                .setMultiChoiceItems(
+                        capabilityNames,
+                        capabilitySelected,
+                        (dialog, which, isChecked) -> capabilitySelected[which] = isChecked)
+                .setCancelable(false)
+                .setPositiveButton(
+                        android.R.string.ok,
+                        (dialogBox, id) -> {
+                            for (int i = 0; i < capabilityKeys.length; i++) {
+                                mSharedPreferences
+                                        .edit()
+                                        .putBoolean(capabilityKeys[i], capabilitySelected[i])
+                                        .apply();
+                            }
+                            setCapabilityToSimulator();
+                        })
+                .setNegativeButton(android.R.string.cancel, null)
+                .setTitle("Simulator Capability")
+                .show();
+    }
+
+    private void setCapabilityToSimulator() {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.setDynamicBufferSize(
+                    getFromIntentOrPrefs(EXTRA_SUPPORT_DYNAMIC_SIZE, false));
+        }
+    }
+
+    private static String getModelIdString(long id) {
+        String result = Ascii.toUpperCase(Long.toHexString(id));
+        while (result.length() < MODEL_ID_LENGTH) {
+            result = "0" + result;
+        }
+        return result;
+    }
+
+    private void putFixedModelLocal() {
+        mModelsMap.put(
+                "00000C",
+                Device.newBuilder()
+                        .setId(12)
+                        .setAntiSpoofingKeyPair(AntiSpoofingKeyPair.newBuilder().build())
+                        .setDeviceType(DeviceType.HEADPHONES)
+                        .build());
+    }
+
+    private void setupModelIdSpinner() {
+        mModelIdSpinner = findViewById(R.id.model_id_spinner);
+
+        ArrayAdapter<String> modelIdAdapter =
+                new ArrayAdapter<>(this, android.R.layout.simple_spinner_item);
+        modelIdAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        mModelIdSpinner.setAdapter(modelIdAdapter);
+        resetModelIdSpinner();
+        mModelIdSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position,
+                    long id) {
+                setModelId(mModelsMap.keySet().toArray(new String[0])[position]);
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> adapterView) {
+            }
+        });
+    }
+
+    private void setupRemoteDevices() {
+        if (Strings.isNullOrEmpty(getIntent().getStringExtra(EXTRA_REMOTE_DEVICE_ID))) {
+            mLogger.log("Can't get remote device id");
+            return;
+        }
+        mRemoteDeviceId = getIntent().getStringExtra(EXTRA_REMOTE_DEVICE_ID);
+        mInputStreamListener = new RemoteDeviceListener(this);
+
+        try {
+            mRemoteDevicesManager.registerRemoteDevice(
+                    mRemoteDeviceId,
+                    new RemoteDevice(
+                            mRemoteDeviceId,
+                            StreamIOHandlerFactory.createStreamIOHandler(
+                                    StreamIOHandlerFactory.Type.LOCAL_FILE,
+                                    REMOTE_DEVICE_INPUT_STREAM_URI,
+                                    REMOTE_DEVICE_OUTPUT_STREAM_URI),
+                            mInputStreamListener));
+        } catch (IOException e) {
+            mLogger.log(e, "Failed to create stream IO handler");
+        }
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    @UiThread
+    private void resetModelIdSpinner() {
+        ArrayAdapter adapter = (ArrayAdapter) mModelIdSpinner.getAdapter();
+        if (adapter == null) {
+            return;
+        }
+
+        adapter.clear();
+        if (!mModelsMap.isEmpty()) {
+            for (String modelId : mModelsMap.keySet()) {
+                adapter.add(modelId + "-" + mModelsMap.get(modelId).getName());
+            }
+            mModelIdSpinner.setEnabled(true);
+            int newPos = getPositionFromModelId(getModelId());
+            if (newPos < 0) {
+                String newModelId = mModelsMap.keySet().iterator().next();
+                Toast.makeText(this,
+                        "Can't find Model ID " + getModelId() + " from console, reset it to "
+                                + newModelId, Toast.LENGTH_SHORT).show();
+                setModelId(newModelId);
+                newPos = 0;
+            }
+            mModelIdSpinner.setSelection(newPos, /* animate= */ false);
+        } else {
+            mModelIdSpinner.setEnabled(false);
+        }
+    }
+
+    private String getModelId() {
+        return getFromIntentOrPrefs(EXTRA_MODEL_ID, MODEL_ID_DEFAULT).toUpperCase(Locale.US);
+    }
+
+    private boolean setModelId(String modelId) {
+        String validModelId = getValidModelId(modelId);
+        if (TextUtils.isEmpty(validModelId)) {
+            mLogger.log("Can't do setModelId because inputted modelId is invalid!");
+            return false;
+        }
+
+        if (getModelId().equals(validModelId)) {
+            return false;
+        }
+        mSharedPreferences.edit().putString(EXTRA_MODEL_ID, validModelId).apply();
+        reset();
+        return true;
+    }
+
+    @Nullable
+    private static String getValidModelId(String modelId) {
+        if (TextUtils.isEmpty(modelId) || modelId.length() < MODEL_ID_LENGTH) {
+            return null;
+        }
+
+        return modelId.substring(0, MODEL_ID_LENGTH).toUpperCase(Locale.US);
+    }
+
+    private int getPositionFromModelId(String modelId) {
+        int i = 0;
+        for (String id : mModelsMap.keySet()) {
+            if (id.equals(modelId)) {
+                return i;
+            }
+            i++;
+        }
+        return -1;
+    }
+
+    private void resetAccountKeys() {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.resetAccountKeys();
+            mFastPairSimulator.startAdvertising();
+        }
+    }
+
+    private void resetDeviceName() {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.resetDeviceName();
+        }
+    }
+
+    /** Called via activity_main.xml */
+    public void onResetButtonClicked(View view) {
+        reset();
+    }
+
+    /** Called via activity_main.xml */
+    public void onSendEventStreamMessageButtonClicked(View view) {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.sendEventStreamMessageToRfcommDevices(mEventGroup);
+        }
+    }
+
+    void reset() {
+        Button resetButton = findViewById(R.id.reset_button);
+        if (mModelsMap.isEmpty() || !resetButton.isEnabled()) {
+            return;
+        }
+        resetButton.setText("Resetting...");
+        resetButton.setEnabled(false);
+        mModelIdSpinner.setEnabled(false);
+        mAppLaunchSwitch.setEnabled(false);
+
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.stopAdvertising();
+
+            if (mBluetoothController.getRemoteDevice() != null) {
+                if (mRemoveAllDevicesDuringPairing) {
+                    mFastPairSimulator.removeBond(mBluetoothController.getRemoteDevice());
+                }
+                mBluetoothController.clearRemoteDevice();
+            }
+            // To be safe, also unpair from all phones (this covers the case where you kill +
+            // relaunch the
+            // simulator while paired).
+            if (mRemoveAllDevicesDuringPairing) {
+                mFastPairSimulator.disconnectAllBondedDevices();
+            }
+            // Sometimes a device will still be connected even though it's not bonded. :( Clear
+            // that too.
+            BluetoothProfile profileProxy = mBluetoothController.getA2DPSinkProfileProxy();
+            for (BluetoothDevice device : profileProxy.getConnectedDevices()) {
+                mFastPairSimulator.disconnect(profileProxy, device);
+            }
+        }
+        updateStatusView();
+
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.destroy();
+        }
+        TextView textView = (TextView) findViewById(R.id.text_view);
+        textView.setText("");
+        textView.setMovementMethod(new ScrollingMovementMethod());
+
+        String modelId = getModelId();
+
+        String txPower = getFromIntentOrPrefs(EXTRA_TX_POWER_LEVEL, "HIGH");
+        updateStringStatusView(R.id.tx_power_text_view, "TxPower", txPower);
+
+        String bluetoothAddress = getFromIntentOrPrefs(EXTRA_BLUETOOTH_ADDRESS, "");
+
+        String firmwareVersion = getFromIntentOrPrefs(EXTRA_FIRMWARE_VERSION, "1.1");
+        try {
+            Preconditions.checkArgument(base16().decode(bluetoothAddress).length == 6);
+        } catch (IllegalArgumentException e) {
+            mLogger.log("Invalid BLUETOOTH_ADDRESS extra (%s), using default.", bluetoothAddress);
+            bluetoothAddress = null;
+        }
+        final String finalBluetoothAddress = bluetoothAddress;
+
+        updateStringStatusView(
+                R.id.anti_spoofing_private_key_text_view, ANTI_SPOOFING_KEY_LABEL, "Loading...");
+
+        boolean useRandomSaltForAccountKeyRotation =
+                getFromIntentOrPrefs(EXTRA_USE_RANDOM_SALT_FOR_ACCOUNT_KEY_ROTATION, false);
+
+        Executors.newSingleThreadExecutor().execute(() -> {
+            // Fetch the anti-spoofing key corresponding to this model ID (if it
+            // exists).
+            // The account must have Project Viewer permission for the project
+            // that owns
+            // the model ID (normally discoverer-test or discoverer-devices).
+            byte[] antiSpoofingKey = getAntiSpoofingKey(modelId);
+            String antiSpoofingKeyString;
+            Device device = mModelsMap.get(modelId);
+            if (antiSpoofingKey != null) {
+                antiSpoofingKeyString = base64().encode(antiSpoofingKey);
+            } else {
+                if (mSharedPreferences.getString(KEY_ACCOUNT_NAME, "").isEmpty()) {
+                    antiSpoofingKeyString = "Can't fetch, no account";
+                } else {
+                    if (device == null) {
+                        antiSpoofingKeyString = String.format(Locale.US,
+                                "Can't find model %s from console", modelId);
+                    } else if (!device.hasAntiSpoofingKeyPair()) {
+                        antiSpoofingKeyString = String.format(Locale.US,
+                                "Can't find AntiSpoofingKeyPair for model %s", modelId);
+                    } else if (device.getAntiSpoofingKeyPair().getPrivateKey().isEmpty()) {
+                        antiSpoofingKeyString = String.format(Locale.US,
+                                "Can't find privateKey for model %s", modelId);
+                    } else {
+                        antiSpoofingKeyString = "Unknown error";
+                    }
+                }
+            }
+
+            int desiredIoCapability = getIoCapabilityFromModelId(modelId);
+
+            mBluetoothController.setIoCapability(
+                    /*ioCapabilityClassic=*/ desiredIoCapability,
+                    /*ioCapabilityBLE=*/ desiredIoCapability);
+
+            runOnUiThread(() -> {
+                updateStringStatusView(
+                        R.id.anti_spoofing_private_key_text_view,
+                        ANTI_SPOOFING_KEY_LABEL,
+                        antiSpoofingKeyString);
+                FastPairSimulator.Options option = FastPairSimulator.Options.builder(modelId)
+                        .setAdvertisingModelId(
+                                mAppLaunchSwitch.isChecked() ? MODEL_ID_APP_LAUNCH : modelId)
+                        .setBluetoothAddress(finalBluetoothAddress)
+                        .setTxPowerLevel(toTxPowerLevel(txPower))
+                        .setAdvertisingChangedCallback(isAdvertising -> updateStatusView())
+                        .setAntiSpoofingPrivateKey(antiSpoofingKey)
+                        .setUseRandomSaltForAccountKeyRotation(useRandomSaltForAccountKeyRotation)
+                        .setDataOnlyConnection(device != null && device.getDataOnlyConnection())
+                        .setShowsPasskeyConfirmation(
+                                device.getDeviceType().equals(DeviceType.ANDROID_AUTO))
+                        .setRemoveAllDevicesDuringPairing(mRemoveAllDevicesDuringPairing)
+                        .build();
+                Logger textViewLogger = new Logger(FastPairSimulator.TAG) {
+
+                    @FormatMethod
+                    public void log(@Nullable Throwable exception, String message,
+                            Object... objects) {
+                        super.log(exception, message, objects);
+
+                        String exceptionMessage = (exception == null) ? ""
+                                : " - " + exception.getMessage();
+                        final String finalMessage =
+                                String.format(message, objects) + exceptionMessage;
+
+                        textView.post(() -> {
+                            String newText =
+                                    textView.getText() + "\n\n" + finalMessage;
+                            textView.setText(newText);
+                        });
+                    }
+                };
+                mFastPairSimulator =
+                        new FastPairSimulator(this, option, textViewLogger);
+                mFastPairSimulator.setFirmwareVersion(firmwareVersion);
+                mFailSwitch.setChecked(
+                        mFastPairSimulator.getShouldFailPairing());
+                mAdvOptionSpinner.setSelection(0);
+                setCapabilityToSimulator();
+
+                updateStringStatusView(R.id.bluetooth_address_text_view,
+                        "Bluetooth address",
+                        mFastPairSimulator.getBluetoothAddress());
+
+                updateStringStatusView(R.id.device_name_text_view,
+                        "Device name",
+                        mFastPairSimulator.getDeviceName());
+
+                resetButton.setText("Reset");
+                resetButton.setEnabled(true);
+                mModelIdSpinner.setEnabled(true);
+                mAppLaunchSwitch.setEnabled(true);
+                mFastPairSimulator.setDeviceNameCallback(deviceName ->
+                        updateStringStatusView(
+                                R.id.device_name_text_view,
+                                "Device name", deviceName));
+
+                if (desiredIoCapability == IO_CAPABILITY_IN
+                        || device.getDeviceType().equals(DeviceType.ANDROID_AUTO)) {
+                    mFastPairSimulator.setPasskeyEventCallback(mPasskeyEventCallback);
+                }
+                if (mInputStreamListener != null) {
+                    mInputStreamListener.setFastPairSimulator(mFastPairSimulator);
+                }
+            });
+        });
+    }
+
+    private int getIoCapabilityFromModelId(String modelId) {
+        Device device = mModelsMap.get(modelId);
+        if (device == null) {
+            return IO_CAPABILITY_NONE;
+        } else {
+            if (getAntiSpoofingKey(modelId) == null) {
+                return IO_CAPABILITY_NONE;
+            } else {
+                switch (device.getDeviceType()) {
+                    case INPUT_DEVICE:
+                        return IO_CAPABILITY_IN;
+
+                    case DEVICE_TYPE_UNSPECIFIED:
+                        return IO_CAPABILITY_NONE;
+
+                    // Treats wearable to IO_CAPABILITY_KBDISP for simulator because there seems
+                    // no suitable
+                    // type.
+                    case WEARABLE:
+                        return IO_CAPABILITY_KBDISP;
+
+                    default:
+                        return IO_CAPABILITY_IO;
+                }
+            }
+        }
+    }
+
+    @Nullable
+    ByteString getAccontKey() {
+        if (mFastPairSimulator == null) {
+            return null;
+        }
+        return mFastPairSimulator.getAccountKey();
+    }
+
+    @Nullable
+    private byte[] getAntiSpoofingKey(String modelId) {
+        Device device = mModelsMap.get(modelId);
+        if (device != null
+                && device.hasAntiSpoofingKeyPair()
+                && !device.getAntiSpoofingKeyPair().getPrivateKey().isEmpty()) {
+            return base64().decode(device.getAntiSpoofingKeyPair().getPrivateKey().toStringUtf8());
+        } else if (ANTI_SPOOFING_PRIVATE_KEY_MAP.containsKey(modelId)) {
+            return base64().decode(ANTI_SPOOFING_PRIVATE_KEY_MAP.get(modelId));
+        } else {
+            return null;
+        }
+    }
+
+    private final PasskeyEventCallback mPasskeyEventCallback = new PasskeyEventCallback() {
+        @Override
+        public void onPasskeyRequested(KeyInputCallback keyInputCallback) {
+            showInputPasskeyDialog(keyInputCallback);
+        }
+
+        @Override
+        public void onPasskeyConfirmation(int passkey, Consumer<Boolean> isConfirmed) {
+            showConfirmPasskeyDialog(passkey, isConfirmed);
+        }
+
+        @Override
+        public void onRemotePasskeyReceived(int passkey) {
+            if (mInputPasskeyDialog == null) {
+                return;
+            }
+
+            EditText userInputDialogEditText = mInputPasskeyDialog.findViewById(
+                    R.id.userInputDialog);
+            if (userInputDialogEditText == null) {
+                return;
+            }
+
+            userInputDialogEditText.setText(String.format("%d", passkey));
+        }
+    };
+
+    private void showInputPasskeyDialog(KeyInputCallback keyInputCallback) {
+        if (mInputPasskeyDialog == null) {
+            View userInputView =
+                    LayoutInflater.from(getApplicationContext()).inflate(R.layout.user_input_dialog,
+                            null);
+            EditText userInputDialogEditText = userInputView.findViewById(R.id.userInputDialog);
+            userInputDialogEditText.setHint(R.string.passkey_input_hint);
+            userInputDialogEditText.setInputType(InputType.TYPE_CLASS_NUMBER);
+            mInputPasskeyDialog = new AlertDialog.Builder(MainActivity.this)
+                    .setView(userInputView)
+                    .setCancelable(false)
+                    .setPositiveButton(
+                            android.R.string.ok,
+                            (DialogInterface dialogBox, int id) -> {
+                                String input = userInputDialogEditText.getText().toString();
+                                keyInputCallback.onKeyInput(Integer.parseInt(input));
+                            })
+                    .setNegativeButton(android.R.string.cancel, /* listener= */ null)
+                    .setTitle(R.string.passkey_dialog_title)
+                    .create();
+        }
+        if (!mInputPasskeyDialog.isShowing()) {
+            mInputPasskeyDialog.show();
+        }
+    }
+
+    private void showConfirmPasskeyDialog(int passkey, Consumer<Boolean> isConfirmed) {
+        runOnUiThread(() -> new AlertDialog.Builder(MainActivity.this)
+                .setCancelable(false)
+                .setTitle(R.string.confirm_passkey)
+                .setMessage(String.valueOf(passkey))
+                .setPositiveButton(android.R.string.ok,
+                        (d, w) -> isConfirmed.accept(true))
+                .setNegativeButton(android.R.string.cancel,
+                        (d, w) -> isConfirmed.accept(false))
+                .create()
+                .show());
+    }
+
+    @UiThread
+    private void updateStringStatusView(int id, String name, String value) {
+        ((TextView) findViewById(id)).setText(name + ": " + value);
+    }
+
+    @UiThread
+    private void updateStatusView() {
+        TextView remoteDeviceTextView = (TextView) findViewById(R.id.remote_device_text_view);
+        remoteDeviceTextView.setBackgroundColor(
+                mBluetoothController.getRemoteDevice() != null ? LIGHT_GREEN : Color.LTGRAY);
+        String remoteDeviceString = mBluetoothController.getRemoteDeviceAsString();
+        remoteDeviceTextView.setText("Remote device: " + remoteDeviceString);
+
+        updateBooleanStatusView(
+                R.id.is_advertising_text_view,
+                "BLE advertising",
+                mFastPairSimulator != null && mFastPairSimulator.isAdvertising());
+
+        updateStringStatusView(
+                R.id.scan_mode_text_view,
+                "Mode",
+                FastPairSimulator.scanModeToString(mBluetoothController.getScanMode()));
+
+        boolean isPaired = mBluetoothController.isPaired();
+        updateBooleanStatusView(R.id.is_paired_text_view, "Paired", isPaired);
+
+        updateBooleanStatusView(
+                R.id.is_connected_text_view, "Connected", mBluetoothController.isConnected());
+    }
+
+    @UiThread
+    private void updateBooleanStatusView(int id, String name, boolean value) {
+        TextView view = (TextView) findViewById(id);
+        view.setBackgroundColor(value ? LIGHT_GREEN : Color.LTGRAY);
+        view.setText(name + ": " + (value ? "Yes" : "No"));
+    }
+
+    private String getFromIntentOrPrefs(String key, String defaultValue) {
+        Bundle extras = getIntent().getExtras();
+        extras = extras != null ? extras : new Bundle();
+        SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
+        String value = extras.getString(key, prefs.getString(key, defaultValue));
+        if (value == null) {
+            prefs.edit().remove(key).apply();
+        } else {
+            prefs.edit().putString(key, value).apply();
+        }
+        return value;
+    }
+
+    private boolean getFromIntentOrPrefs(String key, boolean defaultValue) {
+        Bundle extras = getIntent().getExtras();
+        extras = extras != null ? extras : new Bundle();
+        SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
+        boolean value = extras.getBoolean(key, prefs.getBoolean(key, defaultValue));
+        prefs.edit().putBoolean(key, value).apply();
+        return value;
+    }
+
+    private static int toTxPowerLevel(String txPowerLevelString) {
+        switch (txPowerLevelString.toUpperCase()) {
+            case "3":
+            case "HIGH":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_HIGH;
+            case "2":
+            case "MEDIUM":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM;
+            case "1":
+            case "LOW":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_LOW;
+            case "0":
+            case "ULTRA_LOW":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW;
+            default:
+                throw new IllegalArgumentException(
+                        "Unexpected TxPower="
+                                + txPowerLevelString
+                                + ", please provide HIGH, MEDIUM, LOW, or ULTRA_LOW.");
+        }
+    }
+
+    private boolean checkPermissions(String[] permissions) {
+        for (String permission : permissions) {
+            if (checkSelfPermission(permission) != PERMISSION_GRANTED) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @Override
+    protected void onDestroy() {
+        mRemoteDevicesManager.destroy();
+
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.destroy();
+            mBluetoothController.unregisterBluetoothStateReceiver();
+        }
+
+        // Recover the IO capability.
+        mBluetoothController.setIoCapability(
+                /*ioCapabilityClassic=*/ IO_CAPABILITY_IO, /*ioCapabilityBLE=*/
+                IO_CAPABILITY_KBDISP);
+
+        super.onDestroy();
+    }
+
+    @Override
+    public void onRequestPermissionsResult(
+            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        // Relaunch this activity.
+        recreate();
+    }
+
+    void startAdvertisingBatteryInformationBasedOnOption(int option) {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        // Option 0 is "No battery info", it means simulator will not pack battery information when
+        // advertising. For the others with battery info, since we are simulating the Presto's
+        // behavior,
+        // there will always be three battery values.
+        switch (option) {
+            case 0:
+                // Option "0: No battery info"
+                mFastPairSimulator.clearBatteryValues();
+                break;
+            case 1:
+                // Option "1: Show L(⬆) + R(⬆) + C(⬆)"
+                mFastPairSimulator.setSuppressBatteryNotification(false);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(true, 60),
+                        new BatteryValue(true, 61),
+                        new BatteryValue(true, 62));
+                break;
+            case 2:
+                // Option "2: Show L + R + C(unknown)"
+                mFastPairSimulator.setSuppressBatteryNotification(false);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(false, 70),
+                        new BatteryValue(false, 71),
+                        new BatteryValue(false, -1));
+                break;
+            case 3:
+                // Option "3: Show L(low 10) + R(low 9) + C(low 25)"
+                mFastPairSimulator.setSuppressBatteryNotification(false);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(false, 10),
+                        new BatteryValue(false, 9),
+                        new BatteryValue(false, 25));
+                break;
+            case 4:
+                // Option "4: Suppress battery w/o level changes"
+                // Just change the suppress bit and keep the battery values the same as before.
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                break;
+            case 5:
+                // Option "5: Suppress L(low 10) + R(11) + C"
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(false, 10),
+                        new BatteryValue(false, 11),
+                        new BatteryValue(false, 82));
+                break;
+            case 6:
+                // Option "6: Suppress L(low ⬆) + R(low ⬆) + C(low 10)"
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(true, 10),
+                        new BatteryValue(true, 9),
+                        new BatteryValue(false, 10));
+                break;
+            case 7:
+                // Option "7: Suppress L(low ⬆) + R(low ⬆) + C(low ⬆)"
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(true, 10),
+                        new BatteryValue(true, 9),
+                        new BatteryValue(true, 25));
+                break;
+            case 8:
+                // Option "8: Show subsequent pairing notification"
+                mFastPairSimulator.setSuppressSubsequentPairingNotification(false);
+                break;
+            case 9:
+                // Option "9: Suppress subsequent pairing notification"
+                mFastPairSimulator.setSuppressSubsequentPairingNotification(true);
+                break;
+            default:
+                // Unknown option, do nothing.
+                return;
+        }
+
+        mFastPairSimulator.startAdvertising();
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/RemoteDeviceListener.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/RemoteDeviceListener.java
new file mode 100644
index 0000000..fac8cb5
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/RemoteDeviceListener.java
@@ -0,0 +1,164 @@
+/*
+ * 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.fastpair.provider.simulator.app;
+
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.ACCOUNT_KEY;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.ACKNOWLEDGE;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_ADDRESS_BLE;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_ADDRESS_PUBLIC;
+
+import android.nearby.fastpair.provider.FastPairSimulator;
+import android.nearby.fastpair.provider.FastPairSimulator.BatteryValue;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Command;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Command.BatteryInfo;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event;
+import android.nearby.fastpair.provider.simulator.testing.InputStreamListener;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.google.protobuf.ByteString;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+/** Listener for input stream of the remote device. */
+public class RemoteDeviceListener implements InputStreamListener {
+    private static final String TAG = RemoteDeviceListener.class.getSimpleName();
+
+    private final MainActivity mMainActivity;
+    @Nullable
+    private FastPairSimulator mFastPairSimulator;
+
+    public RemoteDeviceListener(MainActivity mainActivity) {
+        this.mMainActivity = mainActivity;
+    }
+
+    @Override
+    public void onInputData(ByteString byteString) {
+        Command command;
+        try {
+            command = Command.parseFrom(byteString);
+        } catch (InvalidProtocolBufferException e) {
+            Log.w(TAG, String.format("%s input data is not a Command",
+                    mMainActivity.mRemoteDeviceId), e);
+            return;
+        }
+
+        mMainActivity.runOnUiThread(() -> {
+            Log.d(TAG, String.format("%s new command %s",
+                    mMainActivity.mRemoteDeviceId, command.getCode()));
+            switch (command.getCode()) {
+                case POLLING:
+                    mMainActivity.sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(ACKNOWLEDGE));
+                    break;
+                case RESET:
+                    mMainActivity.reset();
+                    break;
+                case SHOW_BATTERY:
+                    onShowBattery(command.getBatteryInfo());
+                    break;
+                case HIDE_BATTERY:
+                    onHideBattery();
+                    break;
+                case REQUEST_BLUETOOTH_ADDRESS_BLE:
+                    onRequestBleAddress();
+                    break;
+                case REQUEST_BLUETOOTH_ADDRESS_PUBLIC:
+                    onRequestPublicAddress();
+                    break;
+                case REQUEST_ACCOUNT_KEY:
+                    ByteString accountKey = mMainActivity.getAccontKey();
+                    if (accountKey == null) {
+                        break;
+                    }
+                    mMainActivity.sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(ACCOUNT_KEY)
+                                    .setAccountKey(accountKey));
+                    break;
+            }
+        });
+    }
+
+    @Override
+    public void onClose() {
+        Log.d(TAG, String.format("%s input stream is closed", mMainActivity.mRemoteDeviceId));
+    }
+
+    void setFastPairSimulator(FastPairSimulator fastPairSimulator) {
+        this.mFastPairSimulator = fastPairSimulator;
+    }
+
+    private void onShowBattery(@Nullable BatteryInfo batteryInfo) {
+        if (mFastPairSimulator == null || batteryInfo == null) {
+            Log.w(TAG, "skip showing battery");
+            return;
+        }
+
+        if (batteryInfo.getBatteryValuesCount() != 3) {
+            Log.w(TAG, String.format("skip showing battery: count is not valid %d",
+                    batteryInfo.getBatteryValuesCount()));
+            return;
+        }
+
+        Log.d(TAG, String.format("Show battery %s", batteryInfo));
+
+        if (batteryInfo.hasSuppressNotification()) {
+            mFastPairSimulator.setSuppressBatteryNotification(
+                    batteryInfo.getSuppressNotification());
+        }
+        mFastPairSimulator.setBatteryValues(
+                convertFrom(batteryInfo.getBatteryValues(0)),
+                convertFrom(batteryInfo.getBatteryValues(1)),
+                convertFrom(batteryInfo.getBatteryValues(2)));
+        mFastPairSimulator.startAdvertising();
+    }
+
+    private void onHideBattery() {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        mFastPairSimulator.clearBatteryValues();
+        mFastPairSimulator.startAdvertising();
+    }
+
+    private void onRequestBleAddress() {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        mMainActivity.sendEventToRemoteDevice(
+                Event.newBuilder()
+                        .setCode(BLUETOOTH_ADDRESS_BLE)
+                        .setBleAddress(mFastPairSimulator.getBleAddress()));
+    }
+
+    private void onRequestPublicAddress() {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        mMainActivity.sendEventToRemoteDevice(
+                Event.newBuilder()
+                        .setCode(BLUETOOTH_ADDRESS_PUBLIC)
+                        .setPublicAddress(mFastPairSimulator.getBluetoothAddress()));
+    }
+
+    private static BatteryValue convertFrom(BatteryInfo.BatteryValue batteryValue) {
+        return new BatteryValue(batteryValue.getCharging(), batteryValue.getLevel());
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/InputStreamListener.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/InputStreamListener.java
new file mode 100644
index 0000000..b29225a
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/InputStreamListener.java
@@ -0,0 +1,29 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import com.google.protobuf.ByteString;
+
+/** Listener for input stream. */
+public interface InputStreamListener {
+
+    /** Called when new data {@code byteString} is read from the input stream. */
+    void onInputData(ByteString byteString);
+
+    /** Called when the input stream is closed. */
+    void onClose();
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/LocalFileStreamIOHandler.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/LocalFileStreamIOHandler.java
new file mode 100644
index 0000000..cf8b022
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/LocalFileStreamIOHandler.java
@@ -0,0 +1,119 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.io.BaseEncoding.base16;
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import com.google.protobuf.ByteString;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+
+/**
+ * Opens the {@code inputUri} and {@code outputUri} as local files and provides reading/writing
+ * data operations.
+ *
+ * To support bluetooth testing on real devices, the named pipes are created as local files and the
+ * pipe data are transferred via usb cable, then (1) the peripheral device writes {@code Event} to
+ * the output stream and reads {@code Command} from the input stream (2) the central devices write
+ * {@code Command} to the output stream and read {@code Event} from the input stream.
+ *
+ * The {@code Event} and {@code Command} are special protocols which are defined at
+ * simulator_stream_protocol.proto.
+ */
+public class LocalFileStreamIOHandler implements StreamIOHandler {
+
+    private static final int MAX_IO_DATA_LENGTH_BYTE = 65535;
+
+    private final String mInputPath;
+    private final String mOutputPath;
+
+    LocalFileStreamIOHandler(Uri inputUri, Uri outputUri) throws IOException {
+        if (!isFileExists(inputUri.getPath())) {
+            throw new FileNotFoundException("Input path is not exists.");
+        }
+        if (!isFileExists(outputUri.getPath())) {
+            throw new FileNotFoundException("Output path is not exists.");
+        }
+
+        this.mInputPath = inputUri.getPath();
+        this.mOutputPath = outputUri.getPath();
+    }
+
+    /**
+     * Reads a {@code ByteString} from the input stream. The input stream must be opened before
+     * calling this method.
+     */
+    @Override
+    public ByteString read() throws IOException {
+        try (InputStreamReader inputStream = new InputStreamReader(
+                new FileInputStream(mInputPath))) {
+            int size = inputStream.read();
+            if (size == 0) {
+                throw new IOException(String.format("Missing data size %d", size));
+            }
+
+            if (size > MAX_IO_DATA_LENGTH_BYTE) {
+                throw new IOException("Exceed the maximum data length when reading.");
+            }
+
+            char[] data = new char[size];
+            int count = inputStream.read(data);
+            if (count != size) {
+                throw new IOException(
+                        String.format("Expected size was %s but got %s", size, count));
+            }
+
+            return ByteString.copyFrom(base16().decode(new String(data)));
+        }
+    }
+
+    /**
+     * Writes a {@code output} into the output stream. The output stream must be opened before
+     * calling this method.
+     */
+    @Override
+    public void write(ByteString output) throws IOException {
+        checkArgument(output.size() > 0, "Output data is empty.");
+
+        if (output.size() > MAX_IO_DATA_LENGTH_BYTE) {
+            throw new IOException("Exceed the maximum data length when writing.");
+        }
+
+        try (OutputStreamWriter outputStream =
+                     new OutputStreamWriter(new FileOutputStream(mOutputPath))) {
+            String base16Output = base16().encode(output.toByteArray());
+            outputStream.write(base16Output.length());
+            outputStream.write(base16Output);
+        }
+    }
+
+    private static boolean isFileExists(@Nullable String path) {
+        return path != null && new File(path).exists();
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevice.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevice.java
new file mode 100644
index 0000000..11ec9cb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevice.java
@@ -0,0 +1,46 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+/** Represents a remote device and provides a {@link StreamIOHandler} to communicate with it. */
+public class RemoteDevice {
+    private final String mId;
+    private final StreamIOHandler mStreamIOHandler;
+    private final InputStreamListener mInputStreamListener;
+
+    public RemoteDevice(
+            String id, StreamIOHandler streamIOHandler, InputStreamListener inputStreamListener) {
+        this.mId = id;
+        this.mStreamIOHandler = streamIOHandler;
+        this.mInputStreamListener = inputStreamListener;
+    }
+
+    /** The id used by this device. */
+    public String getId() {
+        return mId;
+    }
+
+    /** The handler processes input and output data channels. */
+    public StreamIOHandler getStreamIOHandler() {
+        return mStreamIOHandler;
+    }
+
+    /** Listener for the input stream. */
+    public InputStreamListener getInputStreamListener() {
+        return mInputStreamListener;
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevicesManager.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevicesManager.java
new file mode 100644
index 0000000..02260c2
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevicesManager.java
@@ -0,0 +1,140 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import android.util.Log;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.protobuf.ByteString;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+
+/**
+ * Manages the IO streams with remote devices.
+ *
+ * <p>The caller must invoke {@link #registerRemoteDevice} before starting to communicate with the
+ * remote device, and invoke {@link #unregisterRemoteDevice} after finishing tasks. If this instance
+ * is not used anymore, the caller need to invoke {@link #destroy} to release all resources.
+ *
+ * <p>All of the methods are thread-safe.
+ */
+public class RemoteDevicesManager {
+    private static final String TAG = "RemoteDevicesManager";
+
+    private final Map<String, RemoteDevice> mRemoteDeviceMap = new HashMap<>();
+    private final ListeningExecutorService mBackgroundExecutor =
+            MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
+    private final ListeningExecutorService mListenInputStreamExecutors =
+            MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+    private final Map<String, ListenableFuture<Void>> mListeningTaskMap = new HashMap<>();
+
+    /**
+     * Opens input and output data streams for {@code remoteDevice} in the background and notifies
+     * the
+     * open result via {@code callback}, and assigns a dedicated executor to listen the input data
+     * stream if data streams are opened successfully. The dedicated executor will invoke the
+     * {@code
+     * remoteDevice.inputStreamListener().onInputData()} directly if the new data exists in the
+     * input
+     * stream and invoke the {@code remoteDevice.inputStreamListener().onClose()} if the input
+     * stream
+     * is closed.
+     */
+    public synchronized void registerRemoteDevice(String id, RemoteDevice remoteDevice) {
+        checkState(mRemoteDeviceMap.put(id, remoteDevice) == null,
+                "The %s is already registered", id);
+        startListeningInputStreamTask(remoteDevice);
+    }
+
+    /**
+     * Closes the data streams for specific remote device {@code id} in the background and notifies
+     * the result via {@code callback}.
+     */
+    public synchronized void unregisterRemoteDevice(String id) {
+        RemoteDevice remoteDevice = mRemoteDeviceMap.remove(id);
+        checkState(remoteDevice != null, "The %s is not registered", id);
+        if (mListeningTaskMap.containsKey(id)) {
+            mListeningTaskMap.remove(id).cancel(/* mayInterruptIfRunning= */ true);
+        }
+    }
+
+    /** Closes all data streams of registered remote devices and stop all background tasks. */
+    public synchronized void destroy() {
+        mRemoteDeviceMap.clear();
+        mListeningTaskMap.clear();
+        mListenInputStreamExecutors.shutdownNow();
+    }
+
+    /**
+     * Writes {@code data} into the output data stream of specific remote device {@code id} in the
+     * background and notifies the result via {@code callback}.
+     */
+    public synchronized void writeDataToRemoteDevice(
+            String id, ByteString data, FutureCallback<Void> callback) {
+        RemoteDevice remoteDevice = mRemoteDeviceMap.get(id);
+        checkState(remoteDevice != null, "The %s is not registered", id);
+
+        runInBackground(() -> {
+            remoteDevice.getStreamIOHandler().write(data);
+            return null;
+        }, callback);
+    }
+
+    private void runInBackground(Callable<Void> callable, FutureCallback<Void> callback) {
+        Futures.addCallback(
+                mBackgroundExecutor.submit(callable), callback, MoreExecutors.directExecutor());
+    }
+
+    private void startListeningInputStreamTask(RemoteDevice remoteDevice) {
+        ListenableFuture<Void> listenFuture = mListenInputStreamExecutors.submit(() -> {
+            Log.i(TAG, "Start listening " + remoteDevice.getId());
+            while (true) {
+                ByteString data;
+                try {
+                    data = remoteDevice.getStreamIOHandler().read();
+                } catch (IOException | IllegalStateException e) {
+                    break;
+                }
+                remoteDevice.getInputStreamListener().onInputData(data);
+            }
+        }, /* result= */ null);
+        Futures.addCallback(listenFuture, new FutureCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+                Log.i(TAG, "Stop listening " + remoteDevice.getId());
+                remoteDevice.getInputStreamListener().onClose();
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, "Stop listening " + remoteDevice.getId() + ", cause: " + t);
+                remoteDevice.getInputStreamListener().onClose();
+            }
+        }, MoreExecutors.directExecutor());
+        mListeningTaskMap.put(remoteDevice.getId(), listenFuture);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandler.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandler.java
new file mode 100644
index 0000000..d5fdb9e
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandler.java
@@ -0,0 +1,43 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import com.google.protobuf.ByteString;
+
+import java.io.IOException;
+
+/**
+ * Opens input and output data channels, then provides read and write operations to the data
+ * channels.
+ */
+public interface StreamIOHandler {
+    /**
+     * Reads stream data from the input channel.
+     *
+     * @return a protocol buffer contains the input message
+     * @throws IOException errors occur when reading the input stream
+     */
+    ByteString read() throws IOException;
+
+    /**
+     * Writes stream data to the output channel.
+     *
+     * @param output a protocol buffer contains the output message
+     * @throws IOException errors occur when writing the output message to output stream
+     */
+    void write(ByteString output) throws IOException;
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandlerFactory.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandlerFactory.java
new file mode 100644
index 0000000..24cfe56
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandlerFactory.java
@@ -0,0 +1,44 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import android.net.Uri;
+
+import java.io.IOException;
+
+/** A simple factory creating {@link StreamIOHandler} according to {@link Type}. */
+public class StreamIOHandlerFactory {
+
+    /** Types for creating {@link StreamIOHandler}. */
+    public enum Type {
+
+        /**
+         * A {@link StreamIOHandler} accepts local file uris and provides reading/writing file
+         * operations.
+         */
+        LOCAL_FILE
+    }
+
+    /** Creates an instance of {@link StreamIOHandler}. */
+    public static StreamIOHandler createStreamIOHandler(Type type, Uri input, Uri output)
+            throws IOException {
+        if (type.equals(Type.LOCAL_FILE)) {
+            return new LocalFileStreamIOHandler(input, output);
+        }
+        throw new IllegalArgumentException(String.format("Can't support %s", type));
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairAdvertiser.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairAdvertiser.java
new file mode 100644
index 0000000..95c077b
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairAdvertiser.java
@@ -0,0 +1,27 @@
+/*
+ * 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.fastpair.provider;
+
+import androidx.annotation.Nullable;
+
+/** Helper for advertising Fast Pair data. */
+public interface FastPairAdvertiser {
+
+    void startAdvertising(@Nullable byte[] serviceData);
+
+    void stopAdvertising();
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java
new file mode 100644
index 0000000..232e84b
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java
@@ -0,0 +1,2417 @@
+/*
+ * 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.fastpair.provider;
+
+import static android.bluetooth.BluetoothAdapter.EXTRA_STATE;
+import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE;
+import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE;
+import static android.bluetooth.BluetoothAdapter.SCAN_MODE_NONE;
+import static android.bluetooth.BluetoothAdapter.STATE_OFF;
+import static android.bluetooth.BluetoothAdapter.STATE_ON;
+import static android.bluetooth.BluetoothDevice.ERROR;
+import static android.bluetooth.BluetoothGattCharacteristic.PERMISSION_READ;
+import static android.bluetooth.BluetoothGattCharacteristic.PERMISSION_WRITE;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_READ;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE;
+import static android.nearby.fastpair.provider.bluetooth.BluetoothManager.wrap;
+import static android.nearby.fastpair.provider.bluetooth.RfcommServer.State.CONNECTED;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.AES_BLOCK_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.encrypt;
+import static com.android.server.nearby.common.bluetooth.fastpair.Bytes.toBytes;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.A2DP_SINK_SERVICE_UUID;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService.BLUETOOTH_SIG_ORGANIZATION_ID;
+import static com.android.server.nearby.common.bluetooth.fastpair.EllipticCurveDiffieHellmanExchange.PUBLIC_KEY_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.MessageStreamHmacEncoder.SECTION_NONCE_LENGTH;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.primitives.Bytes.concat;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothClass.Device.Major;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.AdvertiseSettings;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.nearby.fastpair.provider.EventStreamProtocol.AcknowledgementEventCode;
+import android.nearby.fastpair.provider.EventStreamProtocol.DeviceActionEventCode;
+import android.nearby.fastpair.provider.EventStreamProtocol.DeviceCapabilitySyncEventCode;
+import android.nearby.fastpair.provider.EventStreamProtocol.DeviceConfigurationEventCode;
+import android.nearby.fastpair.provider.EventStreamProtocol.DeviceEventCode;
+import android.nearby.fastpair.provider.EventStreamProtocol.EventGroup;
+import android.nearby.fastpair.provider.bluetooth.BluetoothGattServerConfig;
+import android.nearby.fastpair.provider.bluetooth.BluetoothGattServerConfig.ServiceConfig;
+import android.nearby.fastpair.provider.bluetooth.BluetoothGattServerConnection;
+import android.nearby.fastpair.provider.bluetooth.BluetoothGattServerConnection.Notifier;
+import android.nearby.fastpair.provider.bluetooth.BluetoothGattServerHelper;
+import android.nearby.fastpair.provider.bluetooth.BluetoothGattServlet;
+import android.nearby.fastpair.provider.bluetooth.RfcommServer;
+import android.nearby.fastpair.provider.crypto.Crypto;
+import android.nearby.fastpair.provider.crypto.E2eeCalculator;
+import android.nearby.fastpair.provider.utils.Logger;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.provider.Settings;
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.util.Consumer;
+
+import com.android.server.nearby.common.bloomfilter.BloomFilter;
+import com.android.server.nearby.common.bloomfilter.FastPairBloomFilterHasher;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption;
+import com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress;
+import com.android.server.nearby.common.bluetooth.fastpair.Bytes.Value;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AccountKeyCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.BeaconActionsCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.BeaconActionsCharacteristic.BeaconActionType;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.FirmwareVersionCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.NameCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.PasskeyCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService.BrHandoverDataCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService.ControlPointCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.EllipticCurveDiffieHellmanExchange;
+import com.android.server.nearby.common.bluetooth.fastpair.Ltv;
+import com.android.server.nearby.common.bluetooth.fastpair.MessageStreamHmacEncoder;
+import com.android.server.nearby.common.bluetooth.fastpair.NamingEncoder;
+import com.android.server.nearby.common.bluetooth.fastpair.Reflect;
+import com.android.server.nearby.common.bluetooth.fastpair.ReflectionException;
+
+import com.google.common.base.Ascii;
+import com.google.common.primitives.Bytes;
+import com.google.protobuf.ByteString;
+
+import java.lang.reflect.Method;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Simulates a Fast Pair device (e.g. a headset).
+ *
+ * <p>Note: There are two deviations from the spec:
+ *
+ * <ul>
+ *   <li>Instead of using the public address when in pairing mode (discoverable), it always uses the
+ *       random private address (RPA), because that's how stock Android works. To work around this,
+ *       it implements the BR/EDR Handover profile (which is no longer part of the Fast Pair spec)
+ *       when simulating a keyless device (i.e. Fast Pair 1.0), which allows the phone to ask for
+ *       the public address. When there is an anti-spoofing key, i.e. Fast Pair 2.0, the public
+ *       address is delivered via the Key-based Pairing handshake. b/79374759 tracks fixing this.
+ *   <li>The simulator always identifies its device capabilities as Keyboard/Display, even when
+ *       simulating a keyless (Fast Pair 1.0) device that should identify as NoInput/NoOutput.
+ *       b/79377125 tracks fixing this.
+ * </ul>
+ *
+ * @see {http://go/fast-pair-2-spec}
+ */
+public class FastPairSimulator {
+    public static final String TAG = "FastPairSimulator";
+    private final Logger mLogger;
+
+    private static final int BECOME_DISCOVERABLE_TIMEOUT_SEC = 3;
+
+    private static final int SCAN_MODE_REFRESH_SEC = 30;
+
+    /**
+     * Headphones. Generated by
+     * http://bluetooth-pentest.narod.ru/software/bluetooth_class_of_device-service_generator.html
+     */
+    private static final Value CLASS_OF_DEVICE =
+            new Value(base16().decode("200418"), ByteOrder.BIG_ENDIAN);
+
+    private static final byte[] SUPPORTED_SERVICES_LTV = new Ltv(
+            TransportDiscoveryService.SERVICE_UUIDS_16_BIT_LIST_TYPE,
+            toBytes(ByteOrder.LITTLE_ENDIAN, A2DP_SINK_SERVICE_UUID)
+    ).getBytes();
+    private static final byte[] TDS_CONTROL_POINT_RESPONSE_PARAMETER =
+            Bytes.concat(new byte[]{BLUETOOTH_SIG_ORGANIZATION_ID}, SUPPORTED_SERVICES_LTV);
+
+    private static final String SIMULATOR_FAKE_BLE_ADDRESS = "11:22:33:44:55:66";
+
+    private static final long ADVERTISING_REFRESH_DELAY_1_MIN = TimeUnit.MINUTES.toMillis(1);
+
+    /** The user will be prompted to accept or deny the incoming pairing request */
+    public static final int PAIRING_VARIANT_CONSENT = 3;
+
+    /**
+     * The user will be prompted to enter the passkey displayed on remote device. This is used for
+     * Bluetooth 2.1 pairing.
+     */
+    public static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4;
+
+    /**
+     * The size of account key filter in bytes is (1.2*n + 3), n represents the size of account key,
+     * see https://developers.google.com/nearby/fast-pair/spec#advertising_when_not_discoverable.
+     * However we'd like to advertise something else, so we could only afford 8 account keys.
+     *
+     * <ul>
+     *   <li>BLE flags: 3 bytes
+     *   <li>TxPower: 3 bytes
+     *   <li>FastPair: max 25 bytes
+     *       <ul>
+     *         <li>FastPair service data: 4 bytes
+     *         <li>Flags: 1 byte
+     *         <li>Account key filter: max 14 bytes (1 byte: length + type, 13 bytes: max 8 account
+     *             keys)
+     *         <li>Salt: 2 bytes
+     *         <li>Battery: 4 bytes
+     *       </ul>
+     * </ul>
+     */
+    private String mDeviceFirmwareVersion = "1.1.0";
+
+    private byte[] mSessionNonce;
+
+    private boolean mUseLogFullEvent = true;
+
+    private enum ResultCode {
+        SUCCESS((byte) 0x00),
+        OP_CODE_NOT_SUPPORTED((byte) 0x01),
+        INVALID_PARAMETER((byte) 0x02),
+        UNSUPPORTED_ORGANIZATION_ID((byte) 0x03),
+        OPERATION_FAILED((byte) 0x04);
+
+        private final byte mByteValue;
+
+        ResultCode(byte byteValue) {
+            this.mByteValue = byteValue;
+        }
+    }
+
+    private enum TransportState {
+        OFF((byte) 0x00),
+        ON((byte) 0x01),
+        TEMPORARILY_UNAVAILABLE((byte) 0x10);
+
+        private final byte mByteValue;
+
+        TransportState(byte byteValue) {
+            this.mByteValue = byteValue;
+        }
+    }
+
+    private final Context mContext;
+    private final Options mOptions;
+    private final Handler mUiThreadHandler = new Handler(Looper.getMainLooper());
+    // No thread pool: Only used in test app (outside gmscore) and in javatests/.../gmscore/.
+    private final ScheduledExecutorService mExecutor =
+            Executors.newSingleThreadScheduledExecutor(); // exempt
+    private final BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (mShouldFailPairing) {
+                mLogger.log("Pairing disabled by test app switch");
+                return;
+            }
+            if (mIsDestroyed) {
+                // Sometimes this receiver does not successfully unregister in destroy()
+                // which causes events to occur after the simulator is stopped, so ignore
+                // those events.
+                mLogger.log("Intent received after simulator destroyed, ignoring");
+                return;
+            }
+            BluetoothDevice device = intent.getParcelableExtra(
+                    BluetoothDevice.EXTRA_DEVICE);
+            switch (intent.getAction()) {
+                case BluetoothAdapter.ACTION_SCAN_MODE_CHANGED:
+                    if (isDiscoverable()) {
+                        mIsDiscoverableLatch.countDown();
+                    }
+                    break;
+                case BluetoothDevice.ACTION_PAIRING_REQUEST:
+                    int variant = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT,
+                            ERROR);
+                    int key = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_KEY, ERROR);
+                    mLogger.log(
+                            "Pairing request, variant=%d, key=%s", variant,
+                            key == ERROR ? "(none)" : key);
+
+                    // Prevent Bluetooth Settings from getting the pairing request.
+                    abortBroadcast();
+
+                    mPairingDevice = device;
+                    if (mSecret == null) {
+                        // We haven't done the handshake over GATT to agree on the shared
+                        // secret. For now, just accept anyway (so we can still simulate
+                        // old 1.0 model IDs).
+                        mLogger.log("No handshake, auto-accepting anyway.");
+                        setPasskeyConfirmation(true);
+                    } else if (variant
+                            == BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION) {
+                        // Store the passkey. And check it, since there's a race (see
+                        // method for why). Usually this check is a no-op and we'll get
+                        // the passkey later over GATT.
+                        mLocalPasskey = key;
+                        checkPasskey();
+                    } else if (variant == PAIRING_VARIANT_DISPLAY_PASSKEY) {
+                        if (mPasskeyEventCallback != null) {
+                            mPasskeyEventCallback.onPasskeyRequested(
+                                    FastPairSimulator.this::enterPassKey);
+                        } else {
+                            mLogger.log("passkeyEventCallback is not set!");
+                            enterPassKey(key);
+                        }
+                    } else if (variant == PAIRING_VARIANT_CONSENT) {
+                        setPasskeyConfirmation(true);
+
+                    } else if (variant == BluetoothDevice.PAIRING_VARIANT_PIN) {
+                        if (mPasskeyEventCallback != null) {
+                            mPasskeyEventCallback.onPasskeyRequested(
+                                    (int pin) -> {
+                                        byte[] newPin = convertPinToBytes(
+                                                String.format(Locale.ENGLISH, "%d", pin));
+                                        mPairingDevice.setPin(newPin);
+                                    });
+                        }
+                    } else {
+                        // Reject the pairing request if it's not using the Numeric
+                        // Comparison (aka Passkey Confirmation) method.
+                        setPasskeyConfirmation(false);
+                    }
+                    break;
+                case BluetoothDevice.ACTION_BOND_STATE_CHANGED:
+                    int bondState =
+                            intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE,
+                                    BluetoothDevice.BOND_NONE);
+                    mLogger.log("Bond state to %s changed to %d", device, bondState);
+                    switch (bondState) {
+                        case BluetoothDevice.BOND_BONDING:
+                            // If we've started bonding, we shouldn't be advertising.
+                            mAdvertiser.stopAdvertising();
+                            // Not discoverable anymore, but still connectable.
+                            setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE);
+                            break;
+                        case BluetoothDevice.BOND_BONDED:
+                            // Once bonded, advertise the account keys.
+                            mAdvertiser.startAdvertising(accountKeysServiceData());
+                            setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE);
+
+                            // If it is subsequent pair, we need to add paired device here.
+                            if (mIsSubsequentPair
+                                    && mSecret != null
+                                    && mSecret.length == AES_BLOCK_LENGTH) {
+                                addAccountKey(mSecret, mPairingDevice);
+                            }
+                            break;
+                        case BluetoothDevice.BOND_NONE:
+                            // If the bonding process fails, we should be advertising again.
+                            mAdvertiser.startAdvertising(getServiceData());
+                            break;
+                        default:
+                            break;
+                    }
+                    break;
+                case BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED:
+                    mLogger.log(
+                            "Connection state to %s changed to %d",
+                            device,
+                            intent.getIntExtra(
+                                    BluetoothAdapter.EXTRA_CONNECTION_STATE,
+                                    BluetoothAdapter.STATE_DISCONNECTED));
+                    break;
+                case BluetoothAdapter.ACTION_STATE_CHANGED:
+                    int state = intent.getIntExtra(EXTRA_STATE, -1);
+                    mLogger.log("Bluetooth adapter state=%s", state);
+                    switch (state) {
+                        case STATE_ON:
+                            startRfcommServer();
+                            break;
+                        case STATE_OFF:
+                            stopRfcommServer();
+                            break;
+                        default: // fall out
+                    }
+                    break;
+                default:
+                    mLogger.log(new IllegalArgumentException(intent.toString()),
+                            "Received unexpected intent");
+                    break;
+            }
+        }
+    };
+
+    @Nullable
+    private byte[] convertPinToBytes(@Nullable String pin) {
+        if (TextUtils.isEmpty(pin)) {
+            return null;
+        }
+        byte[] pinBytes;
+        pinBytes = pin.getBytes(StandardCharsets.UTF_8);
+        if (pinBytes.length <= 0 || pinBytes.length > 16) {
+            return null;
+        }
+        return pinBytes;
+    }
+
+    private final NotifiableGattServlet mPasskeyServlet =
+            new NotifiableGattServlet() {
+                @Override
+                // Simulating deprecated API {@code PasskeyCharacteristic.ID} for testing.
+                @SuppressWarnings("deprecation")
+                public BluetoothGattCharacteristic getBaseCharacteristic() {
+                    return new BluetoothGattCharacteristic(
+                            PasskeyCharacteristic.CUSTOM_128_BIT_UUID,
+                            PROPERTY_WRITE | PROPERTY_INDICATE,
+                            PERMISSION_WRITE);
+                }
+
+                @Override
+                public void write(
+                        BluetoothGattServerConnection connection, int offset, byte[] value) {
+                    mLogger.log("Got value from passkey servlet: %s", base16().encode(value));
+                    if (mSecret == null) {
+                        mLogger.log("Ignoring write to passkey characteristic, no pairing secret.");
+                        return;
+                    }
+
+                    try {
+                        mRemotePasskey = PasskeyCharacteristic.decrypt(
+                                PasskeyCharacteristic.Type.SEEKER, mSecret, value);
+                        if (mPasskeyEventCallback != null) {
+                            mPasskeyEventCallback.onRemotePasskeyReceived(mRemotePasskey);
+                        }
+                        checkPasskey();
+                    } catch (GeneralSecurityException e) {
+                        mLogger.log(
+                                "Decrypting passkey value %s failed using key %s",
+                                base16().encode(value), base16().encode(mSecret));
+                    }
+                }
+            };
+
+    private final NotifiableGattServlet mDeviceNameServlet =
+            new NotifiableGattServlet() {
+                @Override
+                // Simulating deprecated API {@code NameCharacteristic.ID} for testing.
+                @SuppressWarnings("deprecation")
+                BluetoothGattCharacteristic getBaseCharacteristic() {
+                    return new BluetoothGattCharacteristic(
+                            NameCharacteristic.CUSTOM_128_BIT_UUID,
+                            PROPERTY_WRITE | PROPERTY_INDICATE,
+                            PERMISSION_WRITE);
+                }
+
+                @Override
+                public void write(
+                        BluetoothGattServerConnection connection, int offset, byte[] value) {
+                    mLogger.log("Got value from device naming servlet: %s", base16().encode(value));
+                    if (mSecret == null) {
+                        mLogger.log("Ignoring write to name characteristic, no pairing secret.");
+                        return;
+                    }
+                    // Parse the device name from seeker to write name into provider.
+                    mLogger.log("Got name byte array size = %d", value.length);
+                    try {
+                        String decryptedDeviceName =
+                                NamingEncoder.decodeNamingPacket(mSecret, value);
+                        if (decryptedDeviceName != null) {
+                            setDeviceName(decryptedDeviceName.getBytes(StandardCharsets.UTF_8));
+                            mLogger.log("write device name = %s", decryptedDeviceName);
+                        }
+                    } catch (GeneralSecurityException e) {
+                        mLogger.log(e, "Failed to decrypt device name.");
+                    }
+                    // For testing to make sure we get the new provider name from simulator.
+                    if (mWriteNameCountDown != null) {
+                        mLogger.log("finish count down latch to write device name.");
+                        mWriteNameCountDown.countDown();
+                    }
+                }
+            };
+
+    private Value mBluetoothAddress;
+    private final FastPairAdvertiser mAdvertiser;
+    private final Map<String, BluetoothGattServerHelper> mBluetoothGattServerHelpers =
+            new HashMap<>();
+    private CountDownLatch mIsDiscoverableLatch = new CountDownLatch(1);
+    private ScheduledFuture<?> mRevertDiscoverableFuture;
+    private boolean mShouldFailPairing = false;
+    private boolean mIsDestroyed = false;
+    private boolean mIsAdvertising;
+    @Nullable
+    private String mBleAddress;
+    private BluetoothDevice mPairingDevice;
+    private int mLocalPasskey;
+    private int mRemotePasskey;
+    @Nullable
+    private byte[] mSecret;
+    @Nullable
+    private byte[] mAccountKey; // The latest account key added.
+    // The first account key added. Eddystone treats that account as the owner of the device.
+    @Nullable
+    private byte[] mOwnerAccountKey;
+    @Nullable
+    private PasskeyConfirmationCallback mPasskeyConfirmationCallback;
+    @Nullable
+    private DeviceNameCallback mDeviceNameCallback;
+    @Nullable
+    private PasskeyEventCallback mPasskeyEventCallback;
+    private final List<BatteryValue> mBatteryValues;
+    private boolean mSuppressBatteryNotification = false;
+    private boolean mSuppressSubsequentPairingNotification = false;
+    HandshakeRequest mHandshakeRequest;
+    @Nullable
+    private CountDownLatch mWriteNameCountDown;
+    private final RfcommServer mRfcommServer = new RfcommServer();
+    private boolean mSupportDynamicBufferSize = false;
+    private NotifiableGattServlet mBeaconActionsServlet;
+    private final FastPairSimulatorDatabase mFastPairSimulatorDatabase;
+    private boolean mIsSubsequentPair = false;
+
+    /** Sets the flag for failing paring for debug purpose. */
+    public void setShouldFailPairing(boolean shouldFailPairing) {
+        this.mShouldFailPairing = shouldFailPairing;
+    }
+
+    /** Gets the flag for failing paring for debug purpose. */
+    public boolean getShouldFailPairing() {
+        return mShouldFailPairing;
+    }
+
+    /** Clear the battery values, then no battery information is packed when advertising. */
+    public void clearBatteryValues() {
+        mBatteryValues.clear();
+    }
+
+    /** Sets the battery items which will be included in the advertisement packet. */
+    public void setBatteryValues(BatteryValue... batteryValues) {
+        this.mBatteryValues.clear();
+        Collections.addAll(this.mBatteryValues, batteryValues);
+    }
+
+    /** Sets whether the battery advertisement packet is within suppress type or not. */
+    public void setSuppressBatteryNotification(boolean suppressBatteryNotification) {
+        this.mSuppressBatteryNotification = suppressBatteryNotification;
+    }
+
+    /** Sets whether the account key data is within suppress type or not. */
+    public void setSuppressSubsequentPairingNotification(boolean isSuppress) {
+        mSuppressSubsequentPairingNotification = isSuppress;
+    }
+
+    /** Calls this to start advertising after some values are changed. */
+    public void startAdvertising() {
+        mAdvertiser.startAdvertising(getServiceData());
+    }
+
+    /** Send Event Message on to rfcomm connected devices. */
+    public void sendEventStreamMessageToRfcommDevices(EventGroup eventGroup) {
+        // Send fake log when event code is logging and type is not using Log_Full event.
+        if (eventGroup == EventGroup.LOGGING && !mUseLogFullEvent) {
+            mRfcommServer.sendFakeEventStreamLoggingMessage(
+                    getDeviceName()
+                            + " "
+                            + getBleAddress()
+                            + " send log at "
+                            + new SimpleDateFormat("HH:mm:ss:SSS", Locale.US)
+                            .format(Calendar.getInstance().getTime()));
+        } else {
+            mRfcommServer.sendFakeEventStreamMessage(eventGroup);
+        }
+    }
+
+    public void setUseLogFullEvent(boolean useLogFullEvent) {
+        this.mUseLogFullEvent = useLogFullEvent;
+    }
+
+    /** An optional way to get advertising status updates. */
+    public interface AdvertisingChangedCallback {
+        /**
+         * Called when we change our BLE advertisement.
+         *
+         * @param isAdvertising the advertising status.
+         */
+        void onAdvertisingChanged(boolean isAdvertising);
+    }
+
+    /** A way for tests to get callbacks when passkey confirmation is invoked. */
+    public interface PasskeyConfirmationCallback {
+        void onPasskeyConfirmation(boolean confirm);
+    }
+
+    /** A way for simulator UI update to get callback when device name is changed. */
+    public interface DeviceNameCallback {
+        void onNameChanged(String deviceName);
+    }
+
+    /**
+     * Callback when there comes a passkey input request from BT service, or receiving remote
+     * device's passkey.
+     */
+    public interface PasskeyEventCallback {
+        void onPasskeyRequested(KeyInputCallback keyInputCallback);
+
+        void onRemotePasskeyReceived(int passkey);
+
+        default void onPasskeyConfirmation(int passkey, Consumer<Boolean> isConfirmed) {
+        }
+    }
+
+    /** Options for the simulator. */
+    public static class Options {
+        private final String mModelId;
+
+        // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+        private final String mAdvertisingModelId;
+
+        @Nullable
+        private final String mBluetoothAddress;
+
+        @Nullable
+        private final String mBleAddress;
+
+        private final boolean mDataOnlyConnection;
+
+        private final int mTxPowerLevel;
+
+        private final boolean mEnableNameCharacteristic;
+
+        private final AdvertisingChangedCallback mAdvertisingChangedCallback;
+
+        private final boolean mIncludeTransportDataDescriptor;
+
+        @Nullable
+        private final byte[] mAntiSpoofingPrivateKey;
+
+        private final boolean mUseRandomSaltForAccountKeyRotation;
+
+        private final boolean mBecomeDiscoverable;
+
+        private final boolean mShowsPasskeyConfirmation;
+
+        private final boolean mEnableBeaconActionsCharacteristic;
+
+        private final boolean mRemoveAllDevicesDuringPairing;
+
+        @Nullable
+        private final ByteString mEddystoneIdentityKey;
+
+        private Options(
+                String modelId,
+                String advertisingModelId,
+                @Nullable String bluetoothAddress,
+                @Nullable String bleAddress,
+                boolean dataOnlyConnection,
+                int txPowerLevel,
+                boolean enableNameCharacteristic,
+                AdvertisingChangedCallback advertisingChangedCallback,
+                boolean includeTransportDataDescriptor,
+                @Nullable byte[] antiSpoofingPrivateKey,
+                boolean useRandomSaltForAccountKeyRotation,
+                boolean becomeDiscoverable,
+                boolean showsPasskeyConfirmation,
+                boolean enableBeaconActionsCharacteristic,
+                boolean removeAllDevicesDuringPairing,
+                @Nullable ByteString eddystoneIdentityKey) {
+            this.mModelId = modelId;
+            this.mAdvertisingModelId = advertisingModelId;
+            this.mBluetoothAddress = bluetoothAddress;
+            this.mBleAddress = bleAddress;
+            this.mDataOnlyConnection = dataOnlyConnection;
+            this.mTxPowerLevel = txPowerLevel;
+            this.mEnableNameCharacteristic = enableNameCharacteristic;
+            this.mAdvertisingChangedCallback = advertisingChangedCallback;
+            this.mIncludeTransportDataDescriptor = includeTransportDataDescriptor;
+            this.mAntiSpoofingPrivateKey = antiSpoofingPrivateKey;
+            this.mUseRandomSaltForAccountKeyRotation = useRandomSaltForAccountKeyRotation;
+            this.mBecomeDiscoverable = becomeDiscoverable;
+            this.mShowsPasskeyConfirmation = showsPasskeyConfirmation;
+            this.mEnableBeaconActionsCharacteristic = enableBeaconActionsCharacteristic;
+            this.mRemoveAllDevicesDuringPairing = removeAllDevicesDuringPairing;
+            this.mEddystoneIdentityKey = eddystoneIdentityKey;
+        }
+
+        public String getModelId() {
+            return mModelId;
+        }
+
+        // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+        public String getAdvertisingModelId() {
+            return mAdvertisingModelId;
+        }
+
+        @Nullable
+        public String getBluetoothAddress() {
+            return mBluetoothAddress;
+        }
+
+        @Nullable
+        public String getBleAddress() {
+            return mBleAddress;
+        }
+
+        public boolean getDataOnlyConnection() {
+            return mDataOnlyConnection;
+        }
+
+        public int getTxPowerLevel() {
+            return mTxPowerLevel;
+        }
+
+        public boolean getEnableNameCharacteristic() {
+            return mEnableNameCharacteristic;
+        }
+
+        public AdvertisingChangedCallback getAdvertisingChangedCallback() {
+            return mAdvertisingChangedCallback;
+        }
+
+        public boolean getIncludeTransportDataDescriptor() {
+            return mIncludeTransportDataDescriptor;
+        }
+
+        @Nullable
+        public byte[] getAntiSpoofingPrivateKey() {
+            return mAntiSpoofingPrivateKey;
+        }
+
+        public boolean getUseRandomSaltForAccountKeyRotation() {
+            return mUseRandomSaltForAccountKeyRotation;
+        }
+
+        public boolean getBecomeDiscoverable() {
+            return mBecomeDiscoverable;
+        }
+
+        public boolean getShowsPasskeyConfirmation() {
+            return mShowsPasskeyConfirmation;
+        }
+
+        public boolean getEnableBeaconActionsCharacteristic() {
+            return mEnableBeaconActionsCharacteristic;
+        }
+
+        public boolean getRemoveAllDevicesDuringPairing() {
+            return mRemoveAllDevicesDuringPairing;
+        }
+
+        @Nullable
+        public ByteString getEddystoneIdentityKey() {
+            return mEddystoneIdentityKey;
+        }
+
+        /** Converts an instance to a builder. */
+        public Builder toBuilder() {
+            return new Options.Builder(this);
+        }
+
+        /** Constructs a builder. */
+        public static Builder builder() {
+            return new Options.Builder();
+        }
+
+        /** @param modelId Must be a 3-byte hex string. */
+        public static Builder builder(String modelId) {
+            return new Options.Builder()
+                    .setModelId(Ascii.toUpperCase(modelId))
+                    .setAdvertisingModelId(Ascii.toUpperCase(modelId))
+                    .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+                    .setAdvertisingChangedCallback(isAdvertising -> {
+                    })
+                    .setIncludeTransportDataDescriptor(true)
+                    .setUseRandomSaltForAccountKeyRotation(false)
+                    .setEnableNameCharacteristic(true)
+                    .setDataOnlyConnection(false)
+                    .setBecomeDiscoverable(true)
+                    .setShowsPasskeyConfirmation(false)
+                    .setEnableBeaconActionsCharacteristic(true)
+                    .setRemoveAllDevicesDuringPairing(true);
+        }
+
+        /** A builder for {@link Options}. */
+        public static class Builder {
+
+            private String mModelId;
+
+            // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+            private String mAdvertisingModelId;
+
+            @Nullable
+            private String mBluetoothAddress;
+
+            @Nullable
+            private String mBleAddress;
+
+            private boolean mDataOnlyConnection;
+
+            private int mTxPowerLevel;
+
+            private boolean mEnableNameCharacteristic;
+
+            private AdvertisingChangedCallback mAdvertisingChangedCallback;
+
+            private boolean mIncludeTransportDataDescriptor;
+
+            @Nullable
+            private byte[] mAntiSpoofingPrivateKey;
+
+            private boolean mUseRandomSaltForAccountKeyRotation;
+
+            private boolean mBecomeDiscoverable;
+
+            private boolean mShowsPasskeyConfirmation;
+
+            private boolean mEnableBeaconActionsCharacteristic;
+
+            private boolean mRemoveAllDevicesDuringPairing;
+
+            @Nullable
+            private ByteString mEddystoneIdentityKey;
+
+            private Builder() {
+            }
+
+            private Builder(Options option) {
+                this.mModelId = option.mModelId;
+                this.mAdvertisingModelId = option.mAdvertisingModelId;
+                this.mBluetoothAddress = option.mBluetoothAddress;
+                this.mBleAddress = option.mBleAddress;
+                this.mDataOnlyConnection = option.mDataOnlyConnection;
+                this.mTxPowerLevel = option.mTxPowerLevel;
+                this.mEnableNameCharacteristic = option.mEnableNameCharacteristic;
+                this.mAdvertisingChangedCallback = option.mAdvertisingChangedCallback;
+                this.mIncludeTransportDataDescriptor = option.mIncludeTransportDataDescriptor;
+                this.mAntiSpoofingPrivateKey = option.mAntiSpoofingPrivateKey;
+                this.mUseRandomSaltForAccountKeyRotation =
+                        option.mUseRandomSaltForAccountKeyRotation;
+                this.mBecomeDiscoverable = option.mBecomeDiscoverable;
+                this.mShowsPasskeyConfirmation = option.mShowsPasskeyConfirmation;
+                this.mEnableBeaconActionsCharacteristic = option.mEnableBeaconActionsCharacteristic;
+                this.mRemoveAllDevicesDuringPairing = option.mRemoveAllDevicesDuringPairing;
+                this.mEddystoneIdentityKey = option.mEddystoneIdentityKey;
+            }
+
+            /**
+             * Must be one of the {@code ADVERTISE_TX_POWER_*} levels in {@link AdvertiseSettings}.
+             * Default is HIGH.
+             */
+            public Builder setTxPowerLevel(int txPowerLevel) {
+                this.mTxPowerLevel = txPowerLevel;
+                return this;
+            }
+
+            /**
+             * Must be a 6-byte hex string (optionally with colons).
+             * Default is this device's BT MAC.
+             */
+            public Builder setBluetoothAddress(@Nullable String bluetoothAddress) {
+                this.mBluetoothAddress = bluetoothAddress;
+                return this;
+            }
+
+            public Builder setBleAddress(@Nullable String bleAddress) {
+                this.mBleAddress = bleAddress;
+                return this;
+            }
+
+            /** A boolean to decide if enable name characteristic as simulator characteristic. */
+            public Builder setEnableNameCharacteristic(boolean enable) {
+                this.mEnableNameCharacteristic = enable;
+                return this;
+            }
+
+            /** @see AdvertisingChangedCallback */
+            public Builder setAdvertisingChangedCallback(
+                    AdvertisingChangedCallback advertisingChangedCallback) {
+                this.mAdvertisingChangedCallback = advertisingChangedCallback;
+                return this;
+            }
+
+            public Builder setDataOnlyConnection(boolean dataOnlyConnection) {
+                this.mDataOnlyConnection = dataOnlyConnection;
+                return this;
+            }
+
+            /**
+             * Set whether to include the Transport Data descriptor, which has the list of supported
+             * profiles. This is required by the spec, but if we can't get it, we recover gracefully
+             * by assuming support for one of {A2DP, Headset}. Default is true.
+             */
+            public Builder setIncludeTransportDataDescriptor(
+                    boolean includeTransportDataDescriptor) {
+                this.mIncludeTransportDataDescriptor = includeTransportDataDescriptor;
+                return this;
+            }
+
+            public Builder setAntiSpoofingPrivateKey(@Nullable byte[] antiSpoofingPrivateKey) {
+                this.mAntiSpoofingPrivateKey = antiSpoofingPrivateKey;
+                return this;
+            }
+
+            public Builder setUseRandomSaltForAccountKeyRotation(
+                    boolean useRandomSaltForAccountKeyRotation) {
+                this.mUseRandomSaltForAccountKeyRotation = useRandomSaltForAccountKeyRotation;
+                return this;
+            }
+
+            // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+            public Builder setAdvertisingModelId(String modelId) {
+                this.mAdvertisingModelId = modelId;
+                return this;
+            }
+
+            public Builder setBecomeDiscoverable(boolean becomeDiscoverable) {
+                this.mBecomeDiscoverable = becomeDiscoverable;
+                return this;
+            }
+
+            public Builder setShowsPasskeyConfirmation(boolean showsPasskeyConfirmation) {
+                this.mShowsPasskeyConfirmation = showsPasskeyConfirmation;
+                return this;
+            }
+
+            public Builder setEnableBeaconActionsCharacteristic(
+                    boolean enableBeaconActionsCharacteristic) {
+                this.mEnableBeaconActionsCharacteristic = enableBeaconActionsCharacteristic;
+                return this;
+            }
+
+            public Builder setRemoveAllDevicesDuringPairing(boolean removeAllDevicesDuringPairing) {
+                this.mRemoveAllDevicesDuringPairing = removeAllDevicesDuringPairing;
+                return this;
+            }
+
+            /**
+             * Non-public because this is required to create a builder. See
+             * {@link Options#builder}.
+             */
+            public Builder setModelId(String modelId) {
+                this.mModelId = modelId;
+                return this;
+            }
+
+            public Builder setEddystoneIdentityKey(@Nullable ByteString eddystoneIdentityKey) {
+                this.mEddystoneIdentityKey = eddystoneIdentityKey;
+                return this;
+            }
+
+            // Custom builder in order to normalize properties. go/autovalue/builders-howto
+            public Options build() {
+                return new Options(
+                        Ascii.toUpperCase(mModelId),
+                        Ascii.toUpperCase(mAdvertisingModelId),
+                        mBluetoothAddress,
+                        mBleAddress,
+                        mDataOnlyConnection,
+                        mTxPowerLevel,
+                        mEnableNameCharacteristic,
+                        mAdvertisingChangedCallback,
+                        mIncludeTransportDataDescriptor,
+                        mAntiSpoofingPrivateKey,
+                        mUseRandomSaltForAccountKeyRotation,
+                        mBecomeDiscoverable,
+                        mShowsPasskeyConfirmation,
+                        mEnableBeaconActionsCharacteristic,
+                        mRemoveAllDevicesDuringPairing,
+                        mEddystoneIdentityKey);
+            }
+        }
+    }
+
+    public FastPairSimulator(Context context, Options options) {
+        this(context, options, new Logger(TAG));
+    }
+
+    public FastPairSimulator(Context context, Options options, Logger logger) {
+        this.mContext = context;
+        this.mOptions = options;
+        this.mLogger = logger;
+
+        this.mBatteryValues = new ArrayList<>();
+
+        String bluetoothAddress =
+                !TextUtils.isEmpty(options.getBluetoothAddress())
+                        ? options.getBluetoothAddress()
+                        : Settings.Secure.getString(context.getContentResolver(),
+                                "bluetooth_address");
+        if (bluetoothAddress == null && VERSION.SDK_INT >= VERSION_CODES.O) {
+            // Requires a modified Android O build for access to bluetoothAdapter.getAddress().
+            // See http://google3/java/com/google/location/nearby/apps/fastpair/simulator/README.md.
+            bluetoothAddress = mBluetoothAdapter.getAddress();
+        }
+        this.mBluetoothAddress =
+                new Value(BluetoothAddress.decode(bluetoothAddress), ByteOrder.BIG_ENDIAN);
+        this.mBleAddress = options.getBleAddress();
+        this.mAdvertiser = new OreoFastPairAdvertiser(this);
+
+        mFastPairSimulatorDatabase = new FastPairSimulatorDatabase(context);
+
+        byte[] deviceName = getDeviceNameInBytes();
+        mLogger.log(
+                "Provider default device name is %s",
+                deviceName != null ? new String(deviceName, StandardCharsets.UTF_8) : null);
+
+        if (mOptions.getDataOnlyConnection()) {
+            // To get BLE address, we need to start advertising first, and then
+            // {@code#setBleAddress} will be called with BLE address.
+            mAdvertiser.startAdvertising(modelIdServiceData(/* forAdvertising= */ true));
+        } else {
+            // Make this so that the simulator doesn't start automatically.
+            // This is tricky since the simulator is used in our integ tests as well.
+            start(mBleAddress != null ? mBleAddress : bluetoothAddress);
+        }
+    }
+
+    public void start(String address) {
+        IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED);
+        filter.addAction(BluetoothDevice.ACTION_PAIRING_REQUEST);
+        filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+        filter.addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED);
+        filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
+        mContext.registerReceiver(mBroadcastReceiver, filter);
+
+        BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
+        BluetoothGattServerHelper bluetoothGattServerHelper =
+                new BluetoothGattServerHelper(mContext, wrap(bluetoothManager));
+        mBluetoothGattServerHelpers.put(address, bluetoothGattServerHelper);
+
+        if (mOptions.getBecomeDiscoverable()) {
+            try {
+                becomeDiscoverable();
+            } catch (InterruptedException | TimeoutException e) {
+                mLogger.log(e, "Error becoming discoverable");
+            }
+        }
+
+        mAdvertiser.startAdvertising(modelIdServiceData(/* forAdvertising= */ true));
+        startGattServer(bluetoothGattServerHelper);
+        startRfcommServer();
+        scheduleAdvertisingRefresh();
+    }
+
+    /**
+     * Regenerate service data on a fixed interval.
+     * This causes the bloom filter to be refreshed and a different salt to be used for rotation.
+     */
+    @SuppressWarnings("FutureReturnValueIgnored")
+    private void scheduleAdvertisingRefresh() {
+        mExecutor.scheduleAtFixedRate(() -> {
+            if (mIsAdvertising) {
+                mAdvertiser.startAdvertising(getServiceData());
+            }
+        }, ADVERTISING_REFRESH_DELAY_1_MIN, ADVERTISING_REFRESH_DELAY_1_MIN, TimeUnit.MILLISECONDS);
+    }
+
+    public void destroy() {
+        try {
+            mLogger.log("Destroying simulator");
+            mIsDestroyed = true;
+            mContext.unregisterReceiver(mBroadcastReceiver);
+            mAdvertiser.stopAdvertising();
+            for (BluetoothGattServerHelper helper : mBluetoothGattServerHelpers.values()) {
+                helper.close();
+            }
+            stopRfcommServer();
+            mDeviceNameCallback = null;
+            mExecutor.shutdownNow();
+        } catch (IllegalArgumentException ignored) {
+            // Happens if you haven't given us permissions yet, so we didn't register the receiver.
+        }
+    }
+
+    public boolean isDestroyed() {
+        return mIsDestroyed;
+    }
+
+    @Nullable
+    public String getBluetoothAddress() {
+        return BluetoothAddress.encode(mBluetoothAddress.getBytes(ByteOrder.BIG_ENDIAN));
+    }
+
+    public boolean isAdvertising() {
+        return mIsAdvertising;
+    }
+
+    public void setIsAdvertising(boolean isAdvertising) {
+        if (this.mIsAdvertising != isAdvertising) {
+            this.mIsAdvertising = isAdvertising;
+            mOptions.getAdvertisingChangedCallback().onAdvertisingChanged(isAdvertising);
+        }
+    }
+
+    public void stopAdvertising() {
+        mAdvertiser.stopAdvertising();
+    }
+
+    public void setBleAddress(String bleAddress) {
+        this.mBleAddress = bleAddress;
+        if (mOptions.getDataOnlyConnection()) {
+            mBluetoothAddress = new Value(BluetoothAddress.decode(bleAddress),
+                    ByteOrder.BIG_ENDIAN);
+            start(bleAddress);
+        }
+        // When BLE address changes, needs to send BLE address to the client again.
+        sendDeviceBleAddress(bleAddress);
+
+        // If we are advertising something other than the model id (e.g. the bloom filter), restart
+        // the advertisement so that it is updated with the new address.
+        if (isAdvertising() && !isDiscoverable()) {
+            mAdvertiser.startAdvertising(getServiceData());
+        }
+    }
+
+    @Nullable
+    public String getBleAddress() {
+        return mBleAddress;
+    }
+
+    // This method is only for testing to make test block until write name success or time out.
+    @VisibleForTesting
+    public void setCountDownLatchToWriteName(CountDownLatch countDownLatch) {
+        mLogger.log("Set up count down latch to write device name.");
+        mWriteNameCountDown = countDownLatch;
+    }
+
+    public boolean areBeaconActionsNotificationsEnabled() {
+        return mBeaconActionsServlet.areNotificationsEnabled();
+    }
+
+    private abstract class NotifiableGattServlet extends BluetoothGattServlet {
+        private final Map<BluetoothGattServerConnection, Notifier> mConnections = new HashMap<>();
+
+        abstract BluetoothGattCharacteristic getBaseCharacteristic();
+
+        @Override
+        public BluetoothGattCharacteristic getCharacteristic() {
+            // Enabling indication requires the Client Characteristic Configuration descriptor.
+            BluetoothGattCharacteristic characteristic = getBaseCharacteristic();
+            characteristic.addDescriptor(
+                    new BluetoothGattDescriptor(
+                            Constants.CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR_UUID,
+                            BluetoothGattDescriptor.PERMISSION_READ
+                                    | BluetoothGattDescriptor.PERMISSION_WRITE));
+            return characteristic;
+        }
+
+        @Override
+        public void enableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+                throws BluetoothGattException {
+            mLogger.log("Registering notifier for %s", getCharacteristic());
+            mConnections.put(connection, notifier);
+        }
+
+        @Override
+        public void disableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+                throws BluetoothGattException {
+            mLogger.log("Removing notifier for %s", getCharacteristic());
+            mConnections.remove(connection);
+        }
+
+        boolean areNotificationsEnabled() {
+            return !mConnections.isEmpty();
+        }
+
+        void sendNotification(byte[] data) {
+            if (mConnections.isEmpty()) {
+                mLogger.log("Not sending notify as no notifier registered");
+                return;
+            }
+            // Needs to be on a separate thread to avoid deadlocking and timing out (waits for a
+            // callback from OS, which happens on the main thread).
+            mExecutor.execute(
+                    () -> {
+                        for (Map.Entry<BluetoothGattServerConnection, Notifier> entry :
+                                mConnections.entrySet()) {
+                            try {
+                                mLogger.log("Sending notify %s to %s",
+                                        getCharacteristic(),
+                                        entry.getKey().getDevice().getAddress());
+                                entry.getValue().notify(data);
+                            } catch (BluetoothException e) {
+                                mLogger.log(
+                                        e,
+                                        "Failed to notify (indicate) result of %s to %s",
+                                        getCharacteristic(),
+                                        entry.getKey().getDevice().getAddress());
+                            }
+                        }
+                    });
+        }
+    }
+
+    private void startRfcommServer() {
+        mRfcommServer.setRequestHandler(this::handleRfcommServerRequest);
+        mRfcommServer.setStateMonitor(state -> {
+            mLogger.log("RfcommServer is in %s state", state);
+            if (CONNECTED.equals(state)) {
+                sendModelId();
+                sendDeviceBleAddress(mBleAddress);
+                sendFirmwareVersion();
+                sendSessionNonce();
+            }
+        });
+        mRfcommServer.start();
+    }
+
+    private void handleRfcommServerRequest(int eventGroup, int eventCode, byte[] data) {
+        switch (eventGroup) {
+            case EventGroup.DEVICE_VALUE:
+                if (data == null) {
+                    break;
+                }
+
+                String deviceValue = base16().encode(data);
+                if (eventCode == DeviceEventCode.DEVICE_CAPABILITY_VALUE) {
+                    mLogger.log("Received phone capability: %s", deviceValue);
+                } else if (eventCode == DeviceEventCode.PLATFORM_TYPE_VALUE) {
+                    mLogger.log("Received platform type: %s", deviceValue);
+                }
+                break;
+            case EventGroup.DEVICE_ACTION_VALUE:
+                if (eventCode == DeviceActionEventCode.DEVICE_ACTION_RING_VALUE) {
+                    mLogger.log("receive device action with ring value, data = %d",
+                            data[0]);
+                    sendDeviceRingActionResponse();
+                    // Simulate notifying the seeker that the ringing has stopped due
+                    // to user interaction (such as tapping the bud).
+                    mUiThreadHandler.postDelayed(this::sendDeviceRingStoppedAction,
+                            5000);
+                }
+                break;
+            case EventGroup.DEVICE_CONFIGURATION_VALUE:
+                if (eventCode == DeviceConfigurationEventCode.CONFIGURATION_BUFFER_SIZE_VALUE) {
+                    mLogger.log(
+                            "receive device action with buffer size value, data = %s",
+                            base16().encode(data));
+                    sendSetBufferActionResponse(data);
+                }
+                break;
+            case EventGroup.DEVICE_CAPABILITY_SYNC_VALUE:
+                if (eventCode == DeviceCapabilitySyncEventCode.REQUEST_CAPABILITY_UPDATE_VALUE) {
+                    mLogger.log("receive device capability update request.");
+                    sendCapabilitySync();
+                }
+                break;
+            default: // fall out
+                break;
+        }
+    }
+
+    private void stopRfcommServer() {
+        mRfcommServer.stop();
+        mRfcommServer.setRequestHandler(null);
+        mRfcommServer.setStateMonitor(null);
+    }
+
+    private void sendModelId() {
+        mLogger.log("Send model ID to the client");
+        mRfcommServer.send(
+                EventGroup.DEVICE_VALUE,
+                DeviceEventCode.DEVICE_MODEL_ID_VALUE,
+                modelIdServiceData(/* forAdvertising= */ false));
+    }
+
+    private void sendDeviceBleAddress(String bleAddress) {
+        mLogger.log("Send BLE address (%s) to the client", bleAddress);
+        if (bleAddress != null) {
+            mRfcommServer.send(
+                    EventGroup.DEVICE_VALUE,
+                    DeviceEventCode.DEVICE_BLE_ADDRESS_VALUE,
+                    BluetoothAddress.decode(bleAddress));
+        }
+    }
+
+    private void sendFirmwareVersion() {
+        mLogger.log("Send Firmware Version (%s) to the client", mDeviceFirmwareVersion);
+        mRfcommServer.send(
+                EventGroup.DEVICE_VALUE,
+                DeviceEventCode.FIRMWARE_VERSION_VALUE,
+                mDeviceFirmwareVersion.getBytes());
+    }
+
+    private void sendSessionNonce() {
+        mLogger.log("Send SessionNonce (%s) to the client", mDeviceFirmwareVersion);
+        SecureRandom secureRandom = new SecureRandom();
+        mSessionNonce = new byte[SECTION_NONCE_LENGTH];
+        secureRandom.nextBytes(mSessionNonce);
+        mRfcommServer.send(
+                EventGroup.DEVICE_VALUE, DeviceEventCode.SECTION_NONCE_VALUE, mSessionNonce);
+    }
+
+    private void sendDeviceRingActionResponse() {
+        mLogger.log("Send device ring action response to the client");
+        mRfcommServer.send(
+                EventGroup.ACKNOWLEDGEMENT_VALUE,
+                AcknowledgementEventCode.ACKNOWLEDGEMENT_ACK_VALUE,
+                new byte[]{
+                        EventGroup.DEVICE_ACTION_VALUE,
+                        DeviceActionEventCode.DEVICE_ACTION_RING_VALUE
+                });
+    }
+
+    private void sendSetBufferActionResponse(byte[] data) {
+        boolean hmacPassed = false;
+        for (ByteString accountKey : getAccountKeys()) {
+            try {
+                if (MessageStreamHmacEncoder.verifyHmac(
+                        accountKey.toByteArray(), mSessionNonce, data)) {
+                    hmacPassed = true;
+                    mLogger.log("Buffer size data matches account key %s",
+                            base16().encode(accountKey.toByteArray()));
+                    break;
+                }
+            } catch (GeneralSecurityException e) {
+                // Ignore.
+            }
+        }
+        if (hmacPassed) {
+            mLogger.log("Send buffer size action response %s to the client", base16().encode(data));
+            mRfcommServer.send(
+                    EventGroup.ACKNOWLEDGEMENT_VALUE,
+                    AcknowledgementEventCode.ACKNOWLEDGEMENT_ACK_VALUE,
+                    new byte[]{
+                            EventGroup.DEVICE_CONFIGURATION_VALUE,
+                            DeviceConfigurationEventCode.CONFIGURATION_BUFFER_SIZE_VALUE,
+                            data[0],
+                            data[1],
+                            data[2]
+                    });
+        } else {
+            mLogger.log("No matched account key for sendSetBufferActionResponse");
+        }
+    }
+
+    private void sendCapabilitySync() {
+        mLogger.log("Send capability sync to the client");
+        if (mSupportDynamicBufferSize) {
+            mLogger.log("Send dynamic buffer size range to the client");
+            mRfcommServer.send(
+                    EventGroup.DEVICE_CAPABILITY_SYNC_VALUE,
+                    DeviceCapabilitySyncEventCode.CONFIGURABLE_BUFFER_SIZE_RANGE_VALUE,
+                    new byte[]{
+                            0x00, 0x01, (byte) 0xf4, 0x00, 0x64, 0x00, (byte) 0xc8,
+                            0x01, 0x00, (byte) 0xff, 0x00, 0x01, 0x00, (byte) 0x88,
+                            0x02, 0x01, (byte) 0xff, 0x01, 0x01, 0x01, (byte) 0x88,
+                            0x03, 0x02, (byte) 0xff, 0x02, 0x01, 0x02, (byte) 0x88,
+                            0x04, 0x03, (byte) 0xff, 0x03, 0x01, 0x03, (byte) 0x88
+                    });
+        }
+    }
+
+    private void sendDeviceRingStoppedAction() {
+        mLogger.log("Sending device ring stopped action to the client");
+        mRfcommServer.send(
+                EventGroup.DEVICE_ACTION_VALUE,
+                DeviceActionEventCode.DEVICE_ACTION_RING_VALUE,
+                // Additional data for stopping ringing on all components.
+                new byte[]{0x00});
+    }
+
+    private void startGattServer(BluetoothGattServerHelper helper) {
+        BluetoothGattServlet tdsControlPointServlet =
+                new NotifiableGattServlet() {
+                    @Override
+                    public BluetoothGattCharacteristic getBaseCharacteristic() {
+                        return new BluetoothGattCharacteristic(ControlPointCharacteristic.ID,
+                                PROPERTY_WRITE | PROPERTY_INDICATE, PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public void write(
+                            BluetoothGattServerConnection connection, int offset, byte[] value)
+                            throws BluetoothGattException {
+                        mLogger.log("Requested TDS Control Point write, value=%s",
+                                base16().encode(value));
+
+                        ResultCode resultCode = checkTdsControlPointRequest(value);
+                        if (resultCode == ResultCode.SUCCESS) {
+                            try {
+                                becomeDiscoverable();
+                            } catch (TimeoutException | InterruptedException e) {
+                                mLogger.log(e, "Failed to become discoverable");
+                                resultCode = ResultCode.OPERATION_FAILED;
+                            }
+                        }
+
+                        mLogger.log("Request complete, resultCode=%s", resultCode);
+
+                        mLogger.log("Sending TDS Control Point response indication");
+                        sendNotification(
+                                Bytes.concat(
+                                        new byte[]{
+                                                getTdsControlPointOpCode(value),
+                                                resultCode.mByteValue,
+                                        },
+                                        resultCode == ResultCode.SUCCESS
+                                                ? TDS_CONTROL_POINT_RESPONSE_PARAMETER
+                                                : new byte[0]));
+                    }
+                };
+
+        BluetoothGattServlet brHandoverDataServlet =
+                new BluetoothGattServlet() {
+
+                    @Override
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        return new BluetoothGattCharacteristic(BrHandoverDataCharacteristic.ID,
+                                PROPERTY_READ, PERMISSION_READ);
+                    }
+
+                    @Override
+                    public byte[] read(BluetoothGattServerConnection connection, int offset) {
+                        return Bytes.concat(
+                                new byte[]{BrHandoverDataCharacteristic.BR_EDR_FEATURES},
+                                mBluetoothAddress.getBytes(ByteOrder.LITTLE_ENDIAN),
+                                CLASS_OF_DEVICE.getBytes(ByteOrder.LITTLE_ENDIAN));
+                    }
+                };
+
+        BluetoothGattServlet bluetoothSigServlet =
+                new BluetoothGattServlet() {
+
+                    @Override
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        BluetoothGattCharacteristic characteristic =
+                                new BluetoothGattCharacteristic(
+                                        TransportDiscoveryService.BluetoothSigDataCharacteristic.ID,
+                                        0 /* no properties */,
+                                        0 /* no permissions */);
+
+                        if (mOptions.getIncludeTransportDataDescriptor()) {
+                            characteristic.addDescriptor(
+                                    new BluetoothGattDescriptor(
+                                            TransportDiscoveryService.BluetoothSigDataCharacteristic
+                                                    .BrTransportBlockDataDescriptor.ID,
+                                            BluetoothGattDescriptor.PERMISSION_READ));
+                        }
+                        return characteristic;
+                    }
+
+                    @Override
+                    public byte[] readDescriptor(
+                            BluetoothGattServerConnection connection,
+                            BluetoothGattDescriptor descriptor,
+                            int offset)
+                            throws BluetoothGattException {
+                        return transportDiscoveryData();
+                    }
+                };
+
+        BluetoothGattServlet accountKeyServlet =
+                new BluetoothGattServlet() {
+                    @Override
+                    // Simulating deprecated API {@code AccountKeyCharacteristic.ID} for testing.
+                    @SuppressWarnings("deprecation")
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                AccountKeyCharacteristic.CUSTOM_128_BIT_UUID,
+                                PROPERTY_WRITE,
+                                PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public void write(
+                            BluetoothGattServerConnection connection, int offset, byte[] value) {
+                        mLogger.log("Got value from account key servlet: %s",
+                                base16().encode(value));
+                        try {
+                            addAccountKey(AesEcbSingleBlockEncryption.decrypt(mSecret, value),
+                                    mPairingDevice);
+                        } catch (GeneralSecurityException e) {
+                            mLogger.log(e, "Failed to decrypt account key.");
+                        }
+                        mUiThreadHandler.post(
+                                () -> mAdvertiser.startAdvertising(accountKeysServiceData()));
+                    }
+                };
+
+        BluetoothGattServlet firmwareVersionServlet =
+                new BluetoothGattServlet() {
+                    @Override
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                FirmwareVersionCharacteristic.ID, PROPERTY_READ, PERMISSION_READ);
+                    }
+
+                    @Override
+                    public byte[] read(BluetoothGattServerConnection connection, int offset) {
+                        return mDeviceFirmwareVersion.getBytes();
+                    }
+                };
+
+        BluetoothGattServlet keyBasedPairingServlet =
+                new NotifiableGattServlet() {
+                    @Override
+                    // Simulating deprecated API {@code KeyBasedPairingCharacteristic.ID} for
+                    // testing.
+                    @SuppressWarnings("deprecation")
+                    public BluetoothGattCharacteristic getBaseCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                KeyBasedPairingCharacteristic.CUSTOM_128_BIT_UUID,
+                                PROPERTY_WRITE | PROPERTY_INDICATE,
+                                PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public void write(
+                            BluetoothGattServerConnection connection, int offset, byte[] value) {
+                        mLogger.log("Requesting key based pairing handshake, value=%s",
+                                base16().encode(value));
+
+                        mSecret = null;
+                        byte[] seekerPublicAddress = null;
+                        if (value.length == AES_BLOCK_LENGTH) {
+
+                            for (ByteString key : getAccountKeys()) {
+                                byte[] candidateSecret = key.toByteArray();
+                                try {
+                                    seekerPublicAddress = handshake(candidateSecret, value);
+                                    mSecret = candidateSecret;
+                                    mIsSubsequentPair = true;
+                                    break;
+                                } catch (GeneralSecurityException e) {
+                                    mLogger.log(e, "Failed to decrypt with %s",
+                                            base16().encode(candidateSecret));
+                                }
+                            }
+                        } else if (value.length == AES_BLOCK_LENGTH + PUBLIC_KEY_LENGTH
+                                && mOptions.getAntiSpoofingPrivateKey() != null) {
+                            try {
+                                byte[] encryptedRequest = Arrays.copyOf(value, AES_BLOCK_LENGTH);
+                                byte[] receivedPublicKey =
+                                        Arrays.copyOfRange(value, AES_BLOCK_LENGTH, value.length);
+                                byte[] candidateSecret =
+                                        EllipticCurveDiffieHellmanExchange.create(
+                                                        mOptions.getAntiSpoofingPrivateKey())
+                                                .generateSecret(receivedPublicKey);
+                                seekerPublicAddress = handshake(candidateSecret, encryptedRequest);
+                                mSecret = candidateSecret;
+                            } catch (Exception e) {
+                                mLogger.log(
+                                        e,
+                                        "Failed to decrypt with anti-spoofing private key %s",
+                                        base16().encode(mOptions.getAntiSpoofingPrivateKey()));
+                            }
+                        } else {
+                            mLogger.log("Packet length invalid, %d", value.length);
+                            return;
+                        }
+
+                        if (mSecret == null) {
+                            mLogger.log("Couldn't find a usable key to decrypt with.");
+                            return;
+                        }
+
+                        mLogger.log("Found valid decryption key, %s", base16().encode(mSecret));
+                        byte[] salt = new byte[9];
+                        new Random().nextBytes(salt);
+                        try {
+                            byte[] data = concat(
+                                    new byte[]{KeyBasedPairingCharacteristic.Response.TYPE},
+                                    mBluetoothAddress.getBytes(ByteOrder.BIG_ENDIAN), salt);
+                            byte[] encryptedAddress = encrypt(mSecret, data);
+                            mLogger.log(
+                                    "Sending handshake response %s with size %d",
+                                    base16().encode(encryptedAddress), encryptedAddress.length);
+                            sendNotification(encryptedAddress);
+
+                            // Notify seeker for NameCharacteristic to get provider device name
+                            // when seeker request device name flag is true.
+                            if (mOptions.getEnableNameCharacteristic()
+                                    && mHandshakeRequest.requestDeviceName()) {
+                                byte[] encryptedResponse =
+                                        getDeviceNameInBytes() != null ? createEncryptedDeviceName()
+                                                : new byte[0];
+                                mLogger.log(
+                                        "Sending device name response %s with size %d",
+                                        base16().encode(encryptedResponse),
+                                        encryptedResponse.length);
+                                mDeviceNameServlet.sendNotification(encryptedResponse);
+                            }
+
+                            // Disconnects the current connection to allow the following pairing
+                            // request. Needs to be on a separate thread to avoid deadlocking and
+                            // timing out (waits for a callback from OS, which happens on this
+                            // thread).
+                            //
+                            // Note: The spec does not require you to disconnect from other
+                            // devices at this point.
+                            // If headphones support multiple simultaneous connections, they
+                            // should stay connected. But Android fails to pair with the new
+                            // device if we don't first disconnect from any other device.
+                            mLogger.log("Skip remove bond, value=%s",
+                                    mOptions.getRemoveAllDevicesDuringPairing());
+                            if (mOptions.getRemoveAllDevicesDuringPairing()
+                                    && mHandshakeRequest.getType()
+                                    == HandshakeRequest.Type.KEY_BASED_PAIRING_REQUEST
+                                    && !mHandshakeRequest.requestRetroactivePair()) {
+                                mExecutor.execute(() -> disconnectAllBondedDevices());
+                            }
+
+                            if (mHandshakeRequest.getType()
+                                    == HandshakeRequest.Type.KEY_BASED_PAIRING_REQUEST
+                                    && mHandshakeRequest.requestProviderInitialBonding()) {
+                                // Run on executor to ensure it doesn't happen until after the
+                                // notify (which tells the remote device what address to expect).
+                                String seekerPublicAddressString =
+                                        BluetoothAddress.encode(seekerPublicAddress);
+                                mExecutor.execute(() -> {
+                                    mLogger.log("Sending pairing request to %s",
+                                            seekerPublicAddressString);
+                                    mBluetoothAdapter.getRemoteDevice(
+                                            seekerPublicAddressString).createBond();
+                                });
+                            }
+                        } catch (GeneralSecurityException e) {
+                            mLogger.log(e, "Failed to notify of static mac address");
+                        }
+                    }
+
+                    @Nullable
+                    private byte[] handshake(byte[] key, byte[] encryptedPairingRequest)
+                            throws GeneralSecurityException {
+                        mHandshakeRequest = new HandshakeRequest(key, encryptedPairingRequest);
+
+                        byte[] decryptedAddress = mHandshakeRequest.getVerificationData();
+                        if (mBleAddress != null
+                                && Arrays.equals(decryptedAddress,
+                                BluetoothAddress.decode(mBleAddress))
+                                || Arrays.equals(decryptedAddress,
+                                mBluetoothAddress.getBytes(ByteOrder.BIG_ENDIAN))) {
+                            mLogger.log("Address matches: %s", base16().encode(decryptedAddress));
+                        } else {
+                            throw new GeneralSecurityException(
+                                    "Address (BLE or BR/EDR) is not correct: "
+                                            + base16().encode(decryptedAddress)
+                                            + ", "
+                                            + mBleAddress
+                                            + ", "
+                                            + getBluetoothAddress());
+                        }
+
+                        switch (mHandshakeRequest.getType()) {
+                            case KEY_BASED_PAIRING_REQUEST:
+                                return handleKeyBasedPairingRequest(mHandshakeRequest);
+                            case ACTION_OVER_BLE:
+                                return handleActionOverBleRequest(mHandshakeRequest);
+                            case UNKNOWN:
+                                // continue to throw the exception;
+                        }
+                        throw new GeneralSecurityException(
+                                "Type is not correct: " + mHandshakeRequest.getType());
+                    }
+
+                    @Nullable
+                    private byte[] handleKeyBasedPairingRequest(HandshakeRequest handshakeRequest)
+                            throws GeneralSecurityException {
+                        if (handshakeRequest.requestDiscoverable()) {
+                            mLogger.log("Requested discoverability");
+                            setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
+                        }
+
+                        mLogger.log(
+                                "KeyBasedPairing: initialBonding=%s, requestDeviceName=%s, "
+                                        + "retroactivePair=%s",
+                                handshakeRequest.requestProviderInitialBonding(),
+                                handshakeRequest.requestDeviceName(),
+                                handshakeRequest.requestRetroactivePair());
+
+                        byte[] seekerPublicAddress = null;
+                        if (handshakeRequest.requestProviderInitialBonding()
+                                || handshakeRequest.requestRetroactivePair()) {
+                            seekerPublicAddress = handshakeRequest.getSeekerPublicAddress();
+                            mLogger.log(
+                                    "Seeker sends BR/EDR address %s to provider",
+                                    BluetoothAddress.encode(seekerPublicAddress));
+                        }
+
+                        if (handshakeRequest.requestRetroactivePair()) {
+                            if (mBluetoothAdapter.getRemoteDevice(
+                                    seekerPublicAddress).getBondState()
+                                    != BluetoothDevice.BOND_BONDED) {
+                                throw new GeneralSecurityException(
+                                        "Address (BR/EDR) is not bonded: "
+                                                + BluetoothAddress.encode(seekerPublicAddress));
+                            }
+                        }
+
+                        return seekerPublicAddress;
+                    }
+
+                    @Nullable
+                    private byte[] handleActionOverBleRequest(HandshakeRequest handshakeRequest) {
+                        // TODO(wollohchou): implement action over ble request.
+                        if (handshakeRequest.requestDeviceAction()) {
+                            mLogger.log("Requesting action over BLE, device action");
+                        } else if (handshakeRequest.requestFollowedByAdditionalData()) {
+                            mLogger.log(
+                                    "Requesting action over BLE, followed by additional data, "
+                                            + "type:%s",
+                                    handshakeRequest.getAdditionalDataType());
+                        } else {
+                            mLogger.log("Requesting action over BLE");
+                        }
+                        return null;
+                    }
+
+                    /**
+                     * @return The encrypted device name from provider for seeker to use.
+                     */
+                    private byte[] createEncryptedDeviceName() throws GeneralSecurityException {
+                        byte[] deviceName = getDeviceNameInBytes();
+                        String providerName = new String(deviceName, StandardCharsets.UTF_8);
+                        mLogger.log(
+                                "Sending handshake response for device name %s with size %d",
+                                providerName, deviceName.length);
+                        return NamingEncoder.encodeNamingPacket(mSecret, providerName);
+                    }
+                };
+
+        mBeaconActionsServlet =
+                new NotifiableGattServlet() {
+                    private static final int GATT_ERROR_UNAUTHENTICATED = 0x80;
+                    private static final int GATT_ERROR_INVALID_VALUE = 0x81;
+                    private static final int NONCE_LENGTH = 8;
+                    private static final int ONE_TIME_AUTH_KEY_OFFSET = 2;
+                    private static final int ONE_TIME_AUTH_KEY_LENGTH = 8;
+                    private static final int IDENTITY_KEY_LENGTH = 32;
+                    private static final byte TRANSMISSION_POWER = 0;
+
+                    private final SecureRandom mRandom = new SecureRandom();
+                    private final MessageDigest mSha256;
+                    @Nullable
+                    private byte[] mLastNonce;
+                    @Nullable
+                    private ByteString mIdentityKey = mOptions.getEddystoneIdentityKey();
+
+                    {
+                        try {
+                            mSha256 = MessageDigest.getInstance("SHA-256");
+                            mSha256.reset();
+                        } catch (NoSuchAlgorithmException e) {
+                            throw new IllegalStateException(
+                                    "System missing SHA-256 implementation.", e);
+                        }
+                    }
+
+                    @Override
+                    // Simulating deprecated API {@code BeaconActionsCharacteristic.ID} for testing.
+                    @SuppressWarnings("deprecation")
+                    public BluetoothGattCharacteristic getBaseCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                BeaconActionsCharacteristic.CUSTOM_128_BIT_UUID,
+                                PROPERTY_READ | PROPERTY_WRITE | PROPERTY_NOTIFY,
+                                PERMISSION_READ | PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public byte[] read(BluetoothGattServerConnection connection, int offset) {
+                        mLastNonce = new byte[NONCE_LENGTH];
+                        mRandom.nextBytes(mLastNonce);
+                        return mLastNonce;
+                    }
+
+                    @Override
+                    public void write(
+                            BluetoothGattServerConnection connection, int offset, byte[] value)
+                            throws BluetoothGattException {
+                        mLogger.log("Got value from beacon actions servlet: %s",
+                                base16().encode(value));
+                        if (value.length == 0) {
+                            mLogger.log("Packet length invalid, %d", value.length);
+                            throw new BluetoothGattException("Packet length invalid",
+                                    GATT_ERROR_INVALID_VALUE);
+                        }
+                        switch (value[0]) {
+                            case BeaconActionType.READ_BEACON_PARAMETERS:
+                                handleReadBeaconParameters(value);
+                                break;
+                            case BeaconActionType.READ_PROVISIONING_STATE:
+                                handleReadProvisioningState(value);
+                                break;
+                            case BeaconActionType.SET_EPHEMERAL_IDENTITY_KEY:
+                                handleSetEphemeralIdentityKey(value);
+                                break;
+                            case BeaconActionType.CLEAR_EPHEMERAL_IDENTITY_KEY:
+                            case BeaconActionType.READ_EPHEMERAL_IDENTITY_KEY:
+                            case BeaconActionType.RING:
+                            case BeaconActionType.READ_RINGING_STATE:
+                                throw new BluetoothGattException(
+                                        "Unimplemented beacon action",
+                                        BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+                            default:
+                                throw new BluetoothGattException(
+                                        "Unknown beacon action",
+                                        BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+                        }
+                    }
+
+                    private boolean verifyAccountKeyToken(byte[] value, boolean ownerOnly)
+                            throws BluetoothGattException {
+                        if (value.length < ONE_TIME_AUTH_KEY_LENGTH + ONE_TIME_AUTH_KEY_OFFSET) {
+                            mLogger.log("Packet length invalid, %d", value.length);
+                            throw new BluetoothGattException(
+                                    "Packet length invalid", GATT_ERROR_INVALID_VALUE);
+                        }
+                        byte[] hashedAccountKey =
+                                Arrays.copyOfRange(
+                                        value,
+                                        ONE_TIME_AUTH_KEY_OFFSET,
+                                        ONE_TIME_AUTH_KEY_LENGTH + ONE_TIME_AUTH_KEY_OFFSET);
+                        if (mLastNonce == null) {
+                            throw new BluetoothGattException(
+                                    "Nonce wasn't set", GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        if (ownerOnly) {
+                            ByteString accountKey = getOwnerAccountKey();
+                            if (accountKey != null) {
+                                mSha256.update(accountKey.toByteArray());
+                                mSha256.update(mLastNonce);
+                                return Arrays.equals(
+                                        hashedAccountKey,
+                                        Arrays.copyOf(mSha256.digest(), ONE_TIME_AUTH_KEY_LENGTH));
+                            }
+                        } else {
+                            Set<ByteString> accountKeys = getAccountKeys();
+                            for (ByteString accountKey : accountKeys) {
+                                mSha256.update(accountKey.toByteArray());
+                                mSha256.update(mLastNonce);
+                                if (Arrays.equals(
+                                        hashedAccountKey,
+                                        Arrays.copyOf(mSha256.digest(),
+                                                ONE_TIME_AUTH_KEY_LENGTH))) {
+                                    return true;
+                                }
+                            }
+                        }
+                        return false;
+                    }
+
+                    private int getBeaconClock() {
+                        return (int) TimeUnit.MILLISECONDS.toSeconds(SystemClock.elapsedRealtime());
+                    }
+
+                    private ByteString fromBytes(byte... bytes) {
+                        return ByteString.copyFrom(bytes);
+                    }
+
+                    private byte[] intToByteArray(int value) {
+                        byte[] data = new byte[4];
+                        data[3] = (byte) value;
+                        data[2] = (byte) (value >>> 8);
+                        data[1] = (byte) (value >>> 16);
+                        data[0] = (byte) (value >>> 24);
+                        return data;
+                    }
+
+                    private void handleReadBeaconParameters(byte[] value)
+                            throws BluetoothGattException {
+                        if (!verifyAccountKeyToken(value, /* ownerOnly= */ false)) {
+                            throw new BluetoothGattException(
+                                    "failed to authenticate account key",
+                                    GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        sendNotification(
+                                fromBytes(
+                                        (byte) BeaconActionType.READ_BEACON_PARAMETERS,
+                                        (byte) 5 /* data length */,
+                                        TRANSMISSION_POWER)
+                                        .concat(ByteString.copyFrom(
+                                                intToByteArray(getBeaconClock())))
+                                        .toByteArray());
+                    }
+
+                    private void handleReadProvisioningState(byte[] value)
+                            throws BluetoothGattException {
+                        if (!verifyAccountKeyToken(value, /* ownerOnly= */ false)) {
+                            throw new BluetoothGattException(
+                                    "failed to authenticate account key",
+                                    GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        byte flags = 0;
+                        if (verifyAccountKeyToken(value, /* ownerOnly= */ true)) {
+                            flags |= (byte) (1 << 1);
+                        }
+                        if (mIdentityKey == null) {
+                            sendNotification(
+                                    fromBytes(
+                                            (byte) BeaconActionType.READ_PROVISIONING_STATE,
+                                            (byte) 1 /* data length */,
+                                            flags)
+                                            .toByteArray());
+                        } else {
+                            flags |= (byte) 1;
+                            sendNotification(
+                                    fromBytes(
+                                            (byte) BeaconActionType.READ_PROVISIONING_STATE,
+                                            (byte) 21 /* data length */,
+                                            flags)
+                                            .concat(
+                                                    E2eeCalculator.computeE2eeEid(
+                                                            mIdentityKey, /* exponent= */ 10,
+                                                            getBeaconClock()))
+                                            .toByteArray());
+                        }
+                    }
+
+                    private void handleSetEphemeralIdentityKey(byte[] value)
+                            throws BluetoothGattException {
+                        if (!verifyAccountKeyToken(value, /* ownerOnly= */ true)) {
+                            throw new BluetoothGattException(
+                                    "failed to authenticate owner account key",
+                                    GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        if (value.length
+                                != ONE_TIME_AUTH_KEY_LENGTH + ONE_TIME_AUTH_KEY_OFFSET
+                                + IDENTITY_KEY_LENGTH) {
+                            mLogger.log("Packet length invalid, %d", value.length);
+                            throw new BluetoothGattException("Packet length invalid",
+                                    GATT_ERROR_INVALID_VALUE);
+                        }
+                        if (mIdentityKey != null) {
+                            throw new BluetoothGattException(
+                                    "Device is already provisioned as Eddystone",
+                                    GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        mIdentityKey = Crypto.aesEcbNoPaddingDecrypt(
+                                ByteString.copyFrom(mOwnerAccountKey),
+                                ByteString.copyFrom(value)
+                                        .substring(ONE_TIME_AUTH_KEY_LENGTH
+                                                + ONE_TIME_AUTH_KEY_OFFSET));
+                    }
+                };
+
+        ServiceConfig fastPairServiceConfig =
+                new ServiceConfig()
+                        .addCharacteristic(accountKeyServlet)
+                        .addCharacteristic(keyBasedPairingServlet)
+                        .addCharacteristic(mPasskeyServlet)
+                        .addCharacteristic(firmwareVersionServlet);
+        if (mOptions.getEnableBeaconActionsCharacteristic()) {
+            fastPairServiceConfig.addCharacteristic(mBeaconActionsServlet);
+        }
+
+        BluetoothGattServerConfig config =
+                new BluetoothGattServerConfig()
+                        .addService(
+                                TransportDiscoveryService.ID,
+                                new ServiceConfig()
+                                        .addCharacteristic(tdsControlPointServlet)
+                                        .addCharacteristic(brHandoverDataServlet)
+                                        .addCharacteristic(bluetoothSigServlet))
+                        .addService(
+                                FastPairService.ID,
+                                mOptions.getEnableNameCharacteristic()
+                                        ? fastPairServiceConfig.addCharacteristic(
+                                        mDeviceNameServlet)
+                                        : fastPairServiceConfig);
+
+        mLogger.log(
+                "Starting GATT server, support name characteristic %b",
+                mOptions.getEnableNameCharacteristic());
+        try {
+            helper.open(config);
+        } catch (BluetoothException e) {
+            mLogger.log(e, "Error starting GATT server");
+        }
+    }
+
+    /** Callback for passkey/pin input. */
+    public interface KeyInputCallback {
+        void onKeyInput(int key);
+    }
+
+    public void enterPassKey(int passkey) {
+        mLogger.log("enterPassKey called with passkey %d.", passkey);
+        try {
+            boolean result =
+                    (Boolean) Reflect.on(mPairingDevice).withMethod("setPasskey", int.class).get(
+                            passkey);
+            mLogger.log("enterPassKey called with result %b", result);
+        } catch (ReflectionException e) {
+            mLogger.log("enterPassKey meet Exception %s.", e.getMessage());
+        }
+    }
+
+    private void checkPasskey() {
+        // There's a race between the PAIRING_REQUEST broadcast from the OS giving us the local
+        // passkey, and the remote passkey received over GATT. Skip the check until we have both.
+        if (mLocalPasskey == 0 || mRemotePasskey == 0) {
+            mLogger.log(
+                    "Skipping passkey check, missing local (%s) or remote (%s).",
+                    mLocalPasskey, mRemotePasskey);
+            return;
+        }
+
+        // Regardless of whether it matches, send our (encrypted) passkey to the seeker.
+        sendPasskeyToRemoteDevice(mLocalPasskey);
+
+        mLogger.log("Checking localPasskey %s == remotePasskey %s", mLocalPasskey, mRemotePasskey);
+        boolean passkeysMatched = mLocalPasskey == mRemotePasskey;
+        if (mOptions.getShowsPasskeyConfirmation() && passkeysMatched
+                && mPasskeyEventCallback != null) {
+            mLogger.log("callbacks the UI for passkey confirmation.");
+            mPasskeyEventCallback.onPasskeyConfirmation(mLocalPasskey,
+                    this::setPasskeyConfirmation);
+        } else {
+            setPasskeyConfirmation(passkeysMatched);
+        }
+    }
+
+    private void sendPasskeyToRemoteDevice(int passkey) {
+        try {
+            mPasskeyServlet.sendNotification(
+                    PasskeyCharacteristic.encrypt(
+                            PasskeyCharacteristic.Type.PROVIDER, mSecret, passkey));
+        } catch (GeneralSecurityException e) {
+            mLogger.log(e, "Failed to encrypt passkey response.");
+        }
+    }
+
+    public void setFirmwareVersion(String versionNumber) {
+        mDeviceFirmwareVersion = versionNumber;
+    }
+
+    public void setDynamicBufferSize(boolean support) {
+        if (mSupportDynamicBufferSize != support) {
+            mSupportDynamicBufferSize = support;
+            sendCapabilitySync();
+        }
+    }
+
+    @VisibleForTesting
+    void setPasskeyConfirmationCallback(PasskeyConfirmationCallback callback) {
+        this.mPasskeyConfirmationCallback = callback;
+    }
+
+    public void setDeviceNameCallback(DeviceNameCallback callback) {
+        this.mDeviceNameCallback = callback;
+    }
+
+    public void setPasskeyEventCallback(PasskeyEventCallback passkeyEventCallback) {
+        this.mPasskeyEventCallback = passkeyEventCallback;
+    }
+
+    private void setPasskeyConfirmation(boolean confirm) {
+        mPairingDevice.setPairingConfirmation(confirm);
+        if (mPasskeyConfirmationCallback != null) {
+            mPasskeyConfirmationCallback.onPasskeyConfirmation(confirm);
+        }
+        mLocalPasskey = 0;
+        mRemotePasskey = 0;
+    }
+
+    private void becomeDiscoverable() throws InterruptedException, TimeoutException {
+        setDiscoverable(true);
+    }
+
+    public void cancelDiscovery() throws InterruptedException, TimeoutException {
+        setDiscoverable(false);
+    }
+
+    private void setDiscoverable(boolean discoverable)
+            throws InterruptedException, TimeoutException {
+        mIsDiscoverableLatch = new CountDownLatch(1);
+        setScanMode(discoverable ? SCAN_MODE_CONNECTABLE_DISCOVERABLE : SCAN_MODE_CONNECTABLE);
+        // If we're already discoverable, count down the latch right away. Otherwise,
+        // we'll get a broadcast when we successfully become discoverable.
+        if (isDiscoverable()) {
+            mIsDiscoverableLatch.countDown();
+        }
+        if (mIsDiscoverableLatch.await(BECOME_DISCOVERABLE_TIMEOUT_SEC, TimeUnit.SECONDS)) {
+            mLogger.log("Successfully became switched discoverable mode %s", discoverable);
+        } else {
+            throw new TimeoutException();
+        }
+    }
+
+    private void setScanMode(int scanMode) {
+        if (mRevertDiscoverableFuture != null) {
+            mRevertDiscoverableFuture.cancel(false /* may interrupt if running */);
+        }
+
+        mLogger.log("Setting scan mode to %s", scanModeToString(scanMode));
+        try {
+            Method method = mBluetoothAdapter.getClass().getMethod("setScanMode", Integer.TYPE);
+            method.invoke(mBluetoothAdapter, scanMode);
+
+            if (scanMode == SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
+                mRevertDiscoverableFuture =
+                        mExecutor.schedule(() -> setScanMode(SCAN_MODE_CONNECTABLE),
+                                SCAN_MODE_REFRESH_SEC, TimeUnit.SECONDS);
+            }
+        } catch (Exception e) {
+            mLogger.log(e, "Error setting scan mode to %d", scanMode);
+        }
+    }
+
+    public static String scanModeToString(int scanMode) {
+        switch (scanMode) {
+            case SCAN_MODE_CONNECTABLE_DISCOVERABLE:
+                return "DISCOVERABLE";
+            case SCAN_MODE_CONNECTABLE:
+                return "CONNECTABLE";
+            case SCAN_MODE_NONE:
+                return "NOT CONNECTABLE";
+            default:
+                return "UNKNOWN(" + scanMode + ")";
+        }
+    }
+
+    private ResultCode checkTdsControlPointRequest(byte[] request) {
+        if (request.length < 2) {
+            mLogger.log(
+                    new IllegalArgumentException(), "Expected length >= 2 for %s",
+                    base16().encode(request));
+            return ResultCode.INVALID_PARAMETER;
+        }
+        byte opCode = getTdsControlPointOpCode(request);
+        if (opCode != ControlPointCharacteristic.ACTIVATE_TRANSPORT_OP_CODE) {
+            mLogger.log(
+                    new IllegalArgumentException(),
+                    "Expected Activate Transport op code (0x01), got %d",
+                    opCode);
+            return ResultCode.OP_CODE_NOT_SUPPORTED;
+        }
+        if (request[1] != BLUETOOTH_SIG_ORGANIZATION_ID) {
+            mLogger.log(
+                    new IllegalArgumentException(),
+                    "Expected Bluetooth SIG organization ID (0x01), got %d",
+                    request[1]);
+            return ResultCode.UNSUPPORTED_ORGANIZATION_ID;
+        }
+        return ResultCode.SUCCESS;
+    }
+
+    private static byte getTdsControlPointOpCode(byte[] request) {
+        return request.length < 1 ? 0x00 : request[0];
+    }
+
+    private boolean isDiscoverable() {
+        return mBluetoothAdapter.getScanMode() == SCAN_MODE_CONNECTABLE_DISCOVERABLE;
+    }
+
+    private byte[] modelIdServiceData(boolean forAdvertising) {
+        // Note: This used to be little-endian but is now big-endian. See b/78229467 for details.
+        byte[] modelIdPacket =
+                base16().decode(
+                        forAdvertising ? mOptions.getAdvertisingModelId() : mOptions.getModelId());
+        if (!mBatteryValues.isEmpty()) {
+            // If we are going to advertise battery values with the packet, then switch to the
+            // non-3-byte model ID format.
+            modelIdPacket = concat(new byte[]{0b00000110}, modelIdPacket);
+        }
+        return modelIdPacket;
+    }
+
+    private byte[] accountKeysServiceData() {
+        try {
+            return concat(new byte[]{0x00}, generateBloomFilterFields());
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("Unable to build bloom filter.", e);
+        }
+    }
+
+    private byte[] transportDiscoveryData() {
+        byte[] transportData = SUPPORTED_SERVICES_LTV;
+        return Bytes.concat(
+                new byte[]{BLUETOOTH_SIG_ORGANIZATION_ID},
+                new byte[]{tdsFlags(isDiscoverable() ? TransportState.ON : TransportState.OFF)},
+                new byte[]{(byte) transportData.length},
+                transportData);
+    }
+
+    private byte[] generateBloomFilterFields() throws NoSuchAlgorithmException {
+        Set<ByteString> accountKeys = getAccountKeys();
+        if (accountKeys.isEmpty()) {
+            return new byte[0];
+        }
+        BloomFilter bloomFilter =
+                new BloomFilter(
+                        new byte[(int) (1.2 * accountKeys.size()) + 3],
+                        new FastPairBloomFilterHasher());
+        String address = mBleAddress == null ? SIMULATOR_FAKE_BLE_ADDRESS : mBleAddress;
+
+        // Simulator supports Central Address Resolution characteristic, so when paired, the BLE
+        // address in Seeker will be resolved to BR/EDR address. This caused Seeker fails on
+        // checking the bloom filter due to different address is used for salting. In order to
+        // let battery values notification be shown on paired device, we use random salt to
+        // workaround it.
+        boolean advertisingBatteryValues = !mBatteryValues.isEmpty();
+        byte[] salt;
+        if (mOptions.getUseRandomSaltForAccountKeyRotation() || advertisingBatteryValues) {
+            salt = new byte[1];
+            new SecureRandom().nextBytes(salt);
+            mLogger.log("Using random salt %s for bloom filter", base16().encode(salt));
+        } else {
+            salt = BluetoothAddress.decode(address);
+            mLogger.log("Using address %s for bloom filter", address);
+        }
+
+        // To prevent tampering, account filter shall be slightly modified to include battery data
+        // when the battery values are included in the advertisement. Normally, when building the
+        // account filter, a value V is produce by combining the account key with a salt. Instead,
+        // when battery values are also being advertised, it be constructed as follows:
+        // - the first 16 bytes are account key.
+        // - the next bytes are the salt.
+        // - the remaining bytes are the battery data.
+        byte[] saltAndBatteryData =
+                advertisingBatteryValues ? concat(salt, generateBatteryData()) : salt;
+
+        for (ByteString accountKey : accountKeys) {
+            bloomFilter.add(concat(accountKey.toByteArray(), saltAndBatteryData));
+        }
+        byte[] packet = generateAccountKeyData(bloomFilter);
+        return mOptions.getUseRandomSaltForAccountKeyRotation() || advertisingBatteryValues
+                // Create a header with length 1 and type 1 for a random salt.
+                ? concat(packet, createField((byte) 0x11, salt))
+                // Exclude the salt from the packet, BLE address will be assumed by the client.
+                : packet;
+    }
+
+    /**
+     * Creates a new field for the packet.
+     *
+     * The header is formatted 0xLLLLTTTT where LLLL is the
+     * length of the field and TTTT is the type (0 for bloom filter, 1 for salt).
+     */
+    private byte[] createField(byte header, byte[] value) {
+        return concat(new byte[]{header}, value);
+    }
+
+    public int getTxPower() {
+        return mOptions.getTxPowerLevel();
+    }
+
+    @Nullable
+    byte[] getServiceData() {
+        byte[] packet =
+                isDiscoverable()
+                        ? modelIdServiceData(/* forAdvertising= */ true)
+                        : !getAccountKeys().isEmpty() ? accountKeysServiceData() : null;
+        return addBatteryValues(packet);
+    }
+
+    @Nullable
+    private byte[] addBatteryValues(byte[] packet) {
+        if (mBatteryValues.isEmpty() || packet == null) {
+            return packet;
+        }
+
+        return concat(packet, generateBatteryData());
+    }
+
+    private byte[] generateBatteryData() {
+        // Byte 0: Battery length and type, first 4 bits are the number of battery values, second
+        // 4 are the type.
+        // Byte 1 - length: Battery values, the first bit is charging status, the remaining bits are
+        // the actual value between 0 and 100, or -1 for unknown.
+        byte[] batteryData = new byte[mBatteryValues.size() + 1];
+        batteryData[0] = (byte) (mBatteryValues.size() << 4
+                | (mSuppressBatteryNotification ? 0b0100 : 0b0011));
+
+        int batteryValueIndex = 1;
+        for (BatteryValue batteryValue : mBatteryValues) {
+            batteryData[batteryValueIndex++] =
+                    (byte)
+                            ((batteryValue.mCharging ? 0b10000000 : 0b00000000)
+                                    | (0b01111111 & batteryValue.mLevel));
+        }
+
+        return batteryData;
+    }
+
+    private byte[] generateAccountKeyData(BloomFilter bloomFilter) {
+        // Byte 0: length and type, first 4 bits are the length of bloom filter, second 4 are the
+        // type which indicating the subsequent pairing notification is suppressed or not.
+        // The following bytes are the data of bloom filter.
+        byte[] filterBytes = bloomFilter.asBytes();
+        byte lengthAndType = (byte) (filterBytes.length << 4
+                | (mSuppressSubsequentPairingNotification ? 0b0010 : 0b0000));
+        mLogger.log(
+                "Generate bloom filter with suppress subsequent pairing notification:%b",
+                mSuppressSubsequentPairingNotification);
+        return createField(lengthAndType, filterBytes);
+    }
+
+    /** Disconnects all bonded devices. */
+    public void disconnectAllBondedDevices() {
+        for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) {
+            if (device.getBluetoothClass().getMajorDeviceClass() == Major.PHONE) {
+                removeBond(device);
+            }
+        }
+    }
+
+    public void disconnect(BluetoothProfile profile, BluetoothDevice device) {
+        try {
+            Reflect.on(profile).withMethod("disconnect", BluetoothDevice.class).invoke(device);
+        } catch (ReflectionException e) {
+            mLogger.log(e, "Error disconnecting device=%s from profile=%s", device, profile);
+        }
+    }
+
+    public void removeBond(BluetoothDevice device) {
+        try {
+            Reflect.on(device).withMethod("removeBond").invoke();
+        } catch (ReflectionException e) {
+            mLogger.log(e, "Error removing bond for device=%s", device);
+        }
+    }
+
+    public void resetAccountKeys() {
+        mFastPairSimulatorDatabase.setAccountKeys(new HashSet<>());
+        mFastPairSimulatorDatabase.setFastPairSeekerDevices(new HashSet<>());
+        mAccountKey = null;
+        mOwnerAccountKey = null;
+        mLogger.log("Remove all account keys");
+    }
+
+    public void addAccountKey(byte[] key) {
+        addAccountKey(key, /* device= */ null);
+    }
+
+    private void addAccountKey(byte[] key, @Nullable BluetoothDevice device) {
+        mAccountKey = key;
+        if (mOwnerAccountKey == null) {
+            mOwnerAccountKey = key;
+        }
+
+        mFastPairSimulatorDatabase.addAccountKey(key);
+        mFastPairSimulatorDatabase.addFastPairSeekerDevice(device, key);
+        mLogger.log("Add account key: key=%s, device=%s", base16().encode(key), device);
+    }
+
+    private Set<ByteString> getAccountKeys() {
+        return mFastPairSimulatorDatabase.getAccountKeys();
+    }
+
+    /** Get the latest account key. */
+    @Nullable
+    public ByteString getAccountKey() {
+        if (mAccountKey == null) {
+            return null;
+        }
+        return ByteString.copyFrom(mAccountKey);
+    }
+
+    /** Get the owner account key (the first account key registered). */
+    @Nullable
+    public ByteString getOwnerAccountKey() {
+        if (mOwnerAccountKey == null) {
+            return null;
+        }
+        return ByteString.copyFrom(mOwnerAccountKey);
+    }
+
+    public void resetDeviceName() {
+        mFastPairSimulatorDatabase.setLocalDeviceName(null);
+        // Trigger simulator to update device name text view.
+        if (mDeviceNameCallback != null) {
+            mDeviceNameCallback.onNameChanged(getDeviceName());
+        }
+    }
+
+    // This method is used in test case with default name in provider.
+    public void setDeviceName(String deviceName) {
+        setDeviceName(deviceName.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private void setDeviceName(@Nullable byte[] deviceName) {
+        mFastPairSimulatorDatabase.setLocalDeviceName(deviceName);
+
+        mLogger.log("Save device name : %s", getDeviceName());
+        // Trigger simulator to update device name text view.
+        if (mDeviceNameCallback != null) {
+            mDeviceNameCallback.onNameChanged(getDeviceName());
+        }
+    }
+
+    @Nullable
+    private byte[] getDeviceNameInBytes() {
+        return mFastPairSimulatorDatabase.getLocalDeviceName();
+    }
+
+    @Nullable
+    public String getDeviceName() {
+        String providerDeviceName =
+                getDeviceNameInBytes() != null
+                        ? new String(getDeviceNameInBytes(), StandardCharsets.UTF_8)
+                        : null;
+        mLogger.log("get device name = %s", providerDeviceName);
+        return providerDeviceName;
+    }
+
+    /**
+     * Bit index: Description - Value
+     *
+     * <ul>
+     *   <li>0-1: Role - 0b10 (Provider only)
+     *   <li>2: Transport Data Incomplete: 0 (false)
+     *   <li>3-4: Transport State (0b00: Off, 0b01: On, 0b10: Temporarily Unavailable)
+     *   <li>5-7: Reserved for future use
+     * </ul>
+     */
+    private static byte tdsFlags(TransportState transportState) {
+        return (byte) (0b00000010 & (transportState.mByteValue << 3));
+    }
+
+    /** Detailed information about battery value. */
+    public static class BatteryValue {
+        boolean mCharging;
+
+        // The range is 0 ~ 100, and -1 represents the battery level is unknown.
+        int mLevel;
+
+        public BatteryValue(boolean charging, int level) {
+            this.mCharging = charging;
+            this.mLevel = level;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulatorDatabase.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulatorDatabase.java
new file mode 100644
index 0000000..cbe39ff
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulatorDatabase.java
@@ -0,0 +1,264 @@
+/*
+ * 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.fastpair.provider;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.Nullable;
+
+import com.google.protobuf.ByteString;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.StringTokenizer;
+
+/** Stores fast pair related information for each paired device */
+public class FastPairSimulatorDatabase {
+
+    private static final String SHARED_PREF_NAME =
+            "android.nearby.fastpair.provider.fastpairsimulator";
+    private static final String KEY_DEVICE_NAME = "DEVICE_NAME";
+    private static final String KEY_ACCOUNT_KEYS = "ACCOUNT_KEYS";
+    private static final int MAX_NUMBER_OF_ACCOUNT_KEYS = 8;
+
+    // [for SASS]
+    private static final String KEY_FAST_PAIR_SEEKER_DEVICE = "FAST_PAIR_SEEKER_DEVICE";
+
+    private final SharedPreferences mSharedPreferences;
+
+    public FastPairSimulatorDatabase(Context context) {
+        mSharedPreferences = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE);
+    }
+
+    /** Adds single account key. */
+    public void addAccountKey(byte[] accountKey) {
+        if (mSharedPreferences == null) {
+            return;
+        }
+
+        Set<ByteString> accountKeys = new HashSet<>(getAccountKeys());
+        if (accountKeys.size() >= MAX_NUMBER_OF_ACCOUNT_KEYS) {
+            Set<ByteString> removedKeys = new HashSet<>();
+            int removedCount = accountKeys.size() - MAX_NUMBER_OF_ACCOUNT_KEYS + 1;
+            for (ByteString key : accountKeys) {
+                if (removedKeys.size() == removedCount) {
+                    break;
+                }
+                removedKeys.add(key);
+            }
+
+            accountKeys.removeAll(removedKeys);
+        }
+
+        // Just make sure the newest key will not be removed.
+        accountKeys.add(ByteString.copyFrom(accountKey));
+        setAccountKeys(accountKeys);
+    }
+
+    /** Sets account keys, overrides all. */
+    public void setAccountKeys(Set<ByteString> accountKeys) {
+        if (mSharedPreferences == null) {
+            return;
+        }
+
+        Set<String> keys = new HashSet<>();
+        for (ByteString item : accountKeys) {
+            keys.add(base16().encode(item.toByteArray()));
+        }
+
+        mSharedPreferences.edit().putStringSet(KEY_ACCOUNT_KEYS, keys).apply();
+    }
+
+    /** Gets all account keys. */
+    public Set<ByteString> getAccountKeys() {
+        if (mSharedPreferences == null) {
+            return new HashSet<>();
+        }
+
+        Set<String> keys = mSharedPreferences.getStringSet(KEY_ACCOUNT_KEYS, new HashSet<>());
+        Set<ByteString> accountKeys = new HashSet<>();
+        // Add new account keys one by one.
+        for (String key : keys) {
+            accountKeys.add(ByteString.copyFrom(base16().decode(key)));
+        }
+
+        return accountKeys;
+    }
+
+    /** Sets local device name. */
+    public void setLocalDeviceName(byte[] deviceName) {
+        if (mSharedPreferences == null) {
+            return;
+        }
+
+        String humanReadableName = deviceName != null ? new String(deviceName, UTF_8) : null;
+        if (humanReadableName == null) {
+            mSharedPreferences.edit().remove(KEY_DEVICE_NAME).apply();
+        } else {
+            mSharedPreferences.edit().putString(KEY_DEVICE_NAME, humanReadableName).apply();
+        }
+    }
+
+    /** Gets local device name. */
+    @Nullable
+    public byte[] getLocalDeviceName() {
+        if (mSharedPreferences == null) {
+            return null;
+        }
+
+        String deviceName = mSharedPreferences.getString(KEY_DEVICE_NAME, null);
+        return deviceName != null ? deviceName.getBytes(UTF_8) : null;
+    }
+
+    /**
+     * [for SASS] Adds seeker device info. <a
+     * href="http://go/smart-audio-source-switching-design">Sass design doc</a>
+     */
+    public void addFastPairSeekerDevice(@Nullable BluetoothDevice device, byte[] accountKey) {
+        if (mSharedPreferences == null) {
+            return;
+        }
+
+        if (device == null) {
+            return;
+        }
+
+        // When hitting size limitation, choose the existing items to delete.
+        Set<FastPairSeekerDevice> fastPairSeekerDevices = getFastPairSeekerDevices();
+        if (fastPairSeekerDevices.size() > MAX_NUMBER_OF_ACCOUNT_KEYS) {
+            int removedCount = fastPairSeekerDevices.size() - MAX_NUMBER_OF_ACCOUNT_KEYS + 1;
+            Set<FastPairSeekerDevice> removedFastPairDevices = new HashSet<>();
+            for (FastPairSeekerDevice fastPairDevice : fastPairSeekerDevices) {
+                if (removedFastPairDevices.size() == removedCount) {
+                    break;
+                }
+                removedFastPairDevices.add(fastPairDevice);
+            }
+            fastPairSeekerDevices.removeAll(removedFastPairDevices);
+        }
+
+        fastPairSeekerDevices.add(new FastPairSeekerDevice(device, accountKey));
+        setFastPairSeekerDevices(fastPairSeekerDevices);
+    }
+
+    /** [for SASS] Sets all seeker device info, overrides all. */
+    public void setFastPairSeekerDevices(Set<FastPairSeekerDevice> fastPairSeekerDeviceSet) {
+        if (mSharedPreferences == null) {
+            return;
+        }
+
+        Set<String> rawStringSet = new HashSet<>();
+        for (FastPairSeekerDevice item : fastPairSeekerDeviceSet) {
+            rawStringSet.add(item.toRawString());
+        }
+
+        mSharedPreferences.edit().putStringSet(KEY_FAST_PAIR_SEEKER_DEVICE, rawStringSet).apply();
+    }
+
+    /** [for SASS] Gets all seeker device info. */
+    public Set<FastPairSeekerDevice> getFastPairSeekerDevices() {
+        if (mSharedPreferences == null) {
+            return new HashSet<>();
+        }
+
+        Set<FastPairSeekerDevice> fastPairSeekerDevices = new HashSet<>();
+        Set<String> rawStringSet =
+                mSharedPreferences.getStringSet(KEY_FAST_PAIR_SEEKER_DEVICE, new HashSet<>());
+        for (String rawString : rawStringSet) {
+            FastPairSeekerDevice fastPairDevice = FastPairSeekerDevice.fromRawString(rawString);
+            if (fastPairDevice == null) {
+                continue;
+            }
+            fastPairSeekerDevices.add(fastPairDevice);
+        }
+
+        return fastPairSeekerDevices;
+    }
+
+    /** Defines data structure for the paired Fast Pair device. */
+    public static class FastPairSeekerDevice {
+        private static final int INDEX_DEVICE = 0;
+        private static final int INDEX_ACCOUNT_KEY = 1;
+
+        private final BluetoothDevice mDevice;
+        private final byte[] mAccountKey;
+
+        private FastPairSeekerDevice(BluetoothDevice device, byte[] accountKey) {
+            this.mDevice = device;
+            this.mAccountKey = accountKey;
+        }
+
+        public BluetoothDevice getBluetoothDevice() {
+            return mDevice;
+        }
+
+        public byte[] getAccountKey() {
+            return mAccountKey;
+        }
+
+        public String toRawString() {
+            return String.format("%s,%s", mDevice, base16().encode(mAccountKey));
+        }
+
+        /** Decodes the raw string if possible. */
+        @Nullable
+        public static FastPairSeekerDevice fromRawString(String rawString) {
+            BluetoothDevice device = null;
+            byte[] accountKey = null;
+            int step = INDEX_DEVICE;
+
+            StringTokenizer tokenizer = new StringTokenizer(rawString, ",");
+            while (tokenizer.hasMoreElements()) {
+                boolean shouldStop = false;
+                String token = tokenizer.nextToken();
+                switch (step) {
+                    case INDEX_DEVICE:
+                        try {
+                            device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(token);
+                        } catch (IllegalArgumentException e) {
+                            device = null;
+                        }
+                        break;
+                    case INDEX_ACCOUNT_KEY:
+                        accountKey = base16().decode(token);
+                        if (accountKey.length != 16) {
+                            accountKey = null;
+                        }
+                        break;
+                    default:
+                        shouldStop = true;
+                }
+
+                if (shouldStop) {
+                    break;
+                }
+                step++;
+            }
+            if (device != null && accountKey != null) {
+                return new FastPairSeekerDevice(device, accountKey);
+            }
+            return null;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/HandshakeRequest.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/HandshakeRequest.java
new file mode 100644
index 0000000..9cfffd8
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/HandshakeRequest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.fastpair.provider;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.decrypt;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.BLUETOOTH_ADDRESS_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.ActionOverBleFlag.ADDITIONAL_DATA_CHARACTERISTIC;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.ActionOverBleFlag.DEVICE_ACTION;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.PROVIDER_INITIATES_BONDING;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.REQUEST_DEVICE_NAME;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.REQUEST_DISCOVERABLE;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.REQUEST_RETROACTIVE_PAIR;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.ADDITIONAL_DATA_TYPE_INDEX;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AdditionalDataCharacteristic.AdditionalDataType;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+/**
+ * A wrapper for Fast Pair Provider to access decoded handshake request from the Seeker.
+ *
+ * @see {go/fast-pair-early-spec-handshake}
+ */
+public class HandshakeRequest {
+
+    /**
+     * 16 bytes data: 1-byte for type, 1-byte for flags, 6-bytes for provider's BLE address, 8 bytes
+     * optional data.
+     *
+     * @see {go/fast-pair-spec-handshake-message1}
+     */
+    private final byte[] mDecryptedMessage;
+
+    /** Enumerates the handshake message types. */
+    public enum Type {
+        KEY_BASED_PAIRING_REQUEST(Request.TYPE_KEY_BASED_PAIRING_REQUEST),
+        ACTION_OVER_BLE(Request.TYPE_ACTION_OVER_BLE),
+        UNKNOWN((byte) 0xFF);
+
+        private final byte mValue;
+
+        Type(byte type) {
+            mValue = type;
+        }
+
+        public byte getValue() {
+            return mValue;
+        }
+
+        public static Type valueOf(byte value) {
+            for (Type type : Type.values()) {
+                if (type.getValue() == value) {
+                    return type;
+                }
+            }
+            return UNKNOWN;
+        }
+    }
+
+    public HandshakeRequest(byte[] key, byte[] encryptedPairingRequest)
+            throws GeneralSecurityException {
+        mDecryptedMessage = decrypt(key, encryptedPairingRequest);
+    }
+
+    /**
+     * Gets the type of this handshake request. Currently, we have 2 types: 0x00 for Key-based
+     * Pairing Request and 0x10 for Action Request.
+     */
+    public Type getType() {
+        return Type.valueOf(mDecryptedMessage[Request.TYPE_INDEX]);
+    }
+
+    /**
+     * Gets verification data of this handshake request.
+     * Currently, we use Provider's BLE address.
+     */
+    public byte[] getVerificationData() {
+        return Arrays.copyOfRange(
+                mDecryptedMessage,
+                Request.VERIFICATION_DATA_INDEX,
+                Request.VERIFICATION_DATA_INDEX + Request.VERIFICATION_DATA_LENGTH);
+    }
+
+    /** Gets Seeker's public address of the handshake request. */
+    public byte[] getSeekerPublicAddress() {
+        return Arrays.copyOfRange(
+                mDecryptedMessage,
+                Request.SEEKER_PUBLIC_ADDRESS_INDEX,
+                Request.SEEKER_PUBLIC_ADDRESS_INDEX + BLUETOOTH_ADDRESS_LENGTH);
+    }
+
+    /** Checks whether the Seeker request discoverability from flags byte. */
+    public boolean requestDiscoverable() {
+        return (getFlags() & REQUEST_DISCOVERABLE) != 0;
+    }
+
+    /**
+     * Checks whether the Seeker requests that the Provider shall initiate bonding from flags byte.
+     */
+    public boolean requestProviderInitialBonding() {
+        return (getFlags() & PROVIDER_INITIATES_BONDING) != 0;
+    }
+
+    /** Checks whether the Seeker requests that the Provider shall notify the existing name. */
+    public boolean requestDeviceName() {
+        return (getFlags() & REQUEST_DEVICE_NAME) != 0;
+    }
+
+    /** Checks whether this is for retroactively writing account key. */
+    public boolean requestRetroactivePair() {
+        return (getFlags() & REQUEST_RETROACTIVE_PAIR) != 0;
+    }
+
+    /** Gets the flags of this handshake request. */
+    private byte getFlags() {
+        return mDecryptedMessage[Request.FLAGS_INDEX];
+    }
+
+    /** Checks whether the Seeker requests a device action. */
+    public boolean requestDeviceAction() {
+        return (getFlags() & DEVICE_ACTION) != 0;
+    }
+
+    /**
+     * Checks whether the Seeker requests an action which will be followed by an additional data
+     * .
+     */
+    public boolean requestFollowedByAdditionalData() {
+        return (getFlags() & ADDITIONAL_DATA_CHARACTERISTIC) != 0;
+    }
+
+    /** Gets the {@link AdditionalDataType} of this handshake request. */
+    @AdditionalDataType
+    public int getAdditionalDataType() {
+        if (!requestFollowedByAdditionalData()
+                || mDecryptedMessage.length <= ADDITIONAL_DATA_TYPE_INDEX) {
+            return AdditionalDataType.UNKNOWN;
+        }
+        return mDecryptedMessage[ADDITIONAL_DATA_TYPE_INDEX];
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/OreoFastPairAdvertiser.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/OreoFastPairAdvertiser.java
new file mode 100644
index 0000000..bb77c11
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/OreoFastPairAdvertiser.java
@@ -0,0 +1,178 @@
+/*
+ * 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.fastpair.provider;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothAdapter;
+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.fastpair.provider.utils.Logger;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelUuid;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Reflect;
+import com.android.server.nearby.common.bluetooth.fastpair.ReflectionException;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Locale;
+
+/** Fast Pair advertiser taking advantage of new Android Oreo advertising features. */
+@TargetApi(VERSION_CODES.O)
+public final class OreoFastPairAdvertiser implements FastPairAdvertiser {
+    private static final String TAG = "OreoFastPairAdvertiser";
+    private final Logger mLogger = new Logger(TAG);
+
+    private final FastPairSimulator mSimulator;
+    private final BluetoothLeAdvertiser mAdvertiser;
+    private final AdvertisingSetCallback mAdvertisingSetCallback;
+    private AdvertisingSet mAdvertisingSet;
+
+    public OreoFastPairAdvertiser(FastPairSimulator simulator) {
+        this.mSimulator = simulator;
+        this.mAdvertiser = BluetoothAdapter.getDefaultAdapter().getBluetoothLeAdvertiser();
+        this.mAdvertisingSetCallback = new AdvertisingSetCallback() {
+
+            @Override
+            public void onAdvertisingSetStarted(
+                    AdvertisingSet set, int txPower, int status) {
+                if (status == AdvertisingSetCallback.ADVERTISE_SUCCESS) {
+                    mLogger.log("Advertising succeeded, advertising at %s dBm", txPower);
+                    simulator.setIsAdvertising(true);
+                    mAdvertisingSet = set;
+
+                    try {
+                        // Requires custom Android build, see callback below.
+                        Reflect.on(set).withMethod("getOwnAddress").invoke();
+                    } catch (ReflectionException e) {
+                        mLogger.log(e, "Error calling getOwnAddress for AdvertisingSet");
+                    }
+                } else {
+                    mLogger.log(
+                            new IllegalStateException(),
+                            "Advertising failed, error code=%d", status);
+                }
+            }
+
+            @Override
+            public void onAdvertisingDataSet(AdvertisingSet set, int status) {
+                if (status != AdvertisingSetCallback.ADVERTISE_SUCCESS) {
+                    mLogger.log(
+                            new IllegalStateException(),
+                            "Updating advertisement failed, error code=%d",
+                            status);
+                    stopAdvertising();
+                }
+            }
+
+            // Called via reflection with AdvertisingSet.getOwnAddress().
+            public void onOwnAddressRead(
+                    AdvertisingSet set, int addressType, String address) {
+                if (!address.equals(simulator.getBleAddress())) {
+                    mLogger.log(
+                            "Read own BLE address=%s at %s",
+                            address,
+                            new SimpleDateFormat("HH:mm:ss:SSS", Locale.US)
+                                    .format(Calendar.getInstance().getTime()));
+                    // Implicitly start the advertising once BLE address callback arrived.
+                    simulator.setBleAddress(address);
+                }
+            }
+        };
+    }
+
+    @Override
+    public void startAdvertising(@Nullable byte[] serviceData) {
+        // To be informed that BLE address is rotated, we need to polling query it asynchronously.
+        if (mAdvertisingSet != null) {
+            try {
+                // Requires custom Android build, see callback: onOwnAddressRead.
+                Reflect.on(mAdvertisingSet).withMethod("getOwnAddress").invoke();
+            } catch (ReflectionException ignored) {
+                // Ignore it due to user already knows it when setting advertisingSet.
+            }
+        }
+
+        if (mSimulator.isDestroyed()) {
+            return;
+        }
+
+        if (serviceData == null) {
+            mLogger.log("Service data is null, stop advertising");
+            stopAdvertising();
+            return;
+        }
+
+        AdvertiseData data =
+                new AdvertiseData.Builder()
+                        .addServiceData(new ParcelUuid(FastPairService.ID), serviceData)
+                        .setIncludeTxPowerLevel(true)
+                        .build();
+
+        mLogger.log("Advertising FE2C service data=%s", base16().encode(serviceData));
+
+        if (mAdvertisingSet != null) {
+            mAdvertisingSet.setAdvertisingData(data);
+            return;
+        }
+
+        stopAdvertising();
+        AdvertisingSetParameters parameters =
+                new AdvertisingSetParameters.Builder()
+                        .setLegacyMode(true)
+                        .setConnectable(true)
+                        .setScannable(true)
+                        .setInterval(AdvertisingSetParameters.INTERVAL_LOW)
+                        .setTxPowerLevel(convertAdvertiseSettingsTxPower(mSimulator.getTxPower()))
+                        .build();
+        mAdvertiser.startAdvertisingSet(parameters, data, null, null, null,
+                mAdvertisingSetCallback);
+    }
+
+    private static int convertAdvertiseSettingsTxPower(int txPower) {
+        switch (txPower) {
+            case AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW:
+                return AdvertisingSetParameters.TX_POWER_ULTRA_LOW;
+            case AdvertiseSettings.ADVERTISE_TX_POWER_LOW:
+                return AdvertisingSetParameters.TX_POWER_LOW;
+            case AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM:
+                return AdvertisingSetParameters.TX_POWER_MEDIUM;
+            default:
+                return AdvertisingSetParameters.TX_POWER_HIGH;
+        }
+    }
+
+    @Override
+    public void stopAdvertising() {
+        if (mSimulator.isDestroyed()) {
+            return;
+        }
+
+        mAdvertiser.stopAdvertisingSet(mAdvertisingSetCallback);
+        mAdvertisingSet = null;
+        mSimulator.setIsAdvertising(false);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothController.kt b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothController.kt
new file mode 100644
index 0000000..6a3e59e
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothController.kt
@@ -0,0 +1,293 @@
+/*
+ * 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.fastpair.provider.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.nearby.fastpair.provider.FastPairSimulator
+import android.nearby.fastpair.provider.utils.Logger
+import android.nearby.fastpair.provider.utils.Reflect
+import android.nearby.fastpair.provider.utils.ReflectionException
+import android.os.SystemClock
+import android.provider.Settings
+
+/** Controls the local Bluetooth adapter for Fast Pair testing. */
+class BluetoothController(
+    private val context: Context,
+    private val listener: EventListener,
+) : BroadcastReceiver() {
+    private val mLogger = Logger(TAG)
+    private val bluetoothAdapter: BluetoothAdapter =
+        (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter!!
+    private var remoteDevice: BluetoothDevice? = null
+    private var remoteDeviceConnectionState: Int = BluetoothAdapter.STATE_DISCONNECTED
+    private var a2dpSinkProxy: BluetoothProfile? = null
+
+    /** Turns on the local Bluetooth adapter */
+    fun enableBluetooth() {
+        if (!bluetoothAdapter.isEnabled) {
+            bluetoothAdapter.enable()
+            waitForBluetoothState(BluetoothAdapter.STATE_ON)
+        }
+    }
+
+    /**
+     * Sets the Input/Output capability of the device for both classic Bluetooth and BLE operations.
+     * Note: In order to let changes take effect, this method will make sure the Bluetooth stack is
+     * restarted by blocking calling thread.
+     *
+     * @param ioCapabilityClassic One of {@link #IO_CAPABILITY_IO}, {@link #IO_CAPABILITY_NONE},
+     * ```
+     *     {@link #IO_CAPABILITY_KBDISP} or more in {@link BluetoothAdapter}.
+     * @param ioCapabilityBLE
+     * ```
+     * One of {@link #IO_CAPABILITY_IO}, {@link #IO_CAPABILITY_NONE}, {@link
+     * ```
+     *     #IO_CAPABILITY_KBDISP} or more in {@link BluetoothAdapter}.
+     * ```
+     */
+    fun setIoCapability(ioCapabilityClassic: Int, ioCapabilityBLE: Int) {
+        try {
+            Reflect.on(bluetoothAdapter)
+                .withMethod("setIoCapability", Int::class.javaPrimitiveType)[
+                    ioCapabilityClassic]
+        } catch (e: ReflectionException) {
+            mLogger.log(e, "Error setIoCapability to %s: %s", ioCapabilityClassic)
+        }
+        try {
+            Reflect.on(bluetoothAdapter)
+                .withMethod("setLeIoCapability", Int::class.javaPrimitiveType)[
+                    ioCapabilityBLE]
+        } catch (e: ReflectionException) {
+            mLogger.log(e, "Error setLeIoCapability to %s: %s", ioCapabilityBLE)
+        }
+
+        // Toggling airplane mode on/off to restart Bluetooth stack and reset the BLE.
+        // Since it also increases reliability, we will do so even if ReflectionException is caught.
+        try {
+            Settings.Global.putInt(
+                context.contentResolver,
+                Settings.Global.AIRPLANE_MODE_ON,
+                TURN_AIRPLANE_MODE_ON
+            )
+        } catch (expectedOnNonCustomAndroid: SecurityException) {
+            mLogger.log(
+                expectedOnNonCustomAndroid,
+                "Requires custom Android to toggle airplane mode"
+            )
+            // Fall back to turn off Bluetooth.
+            bluetoothAdapter.disable()
+        }
+        waitForBluetoothState(BluetoothAdapter.STATE_OFF)
+        try {
+            Settings.Global.putInt(
+                context.contentResolver,
+                Settings.Global.AIRPLANE_MODE_ON,
+                TURN_AIRPLANE_MODE_OFF
+            )
+        } catch (expectedOnNonCustomAndroid: SecurityException) {
+            mLogger.log(
+                expectedOnNonCustomAndroid,
+                "SecurityException while toggled airplane mode."
+            )
+        } finally {
+            // Double confirm that Bluetooth is turned on.
+            bluetoothAdapter.enable()
+        }
+        waitForBluetoothState(BluetoothAdapter.STATE_ON)
+    }
+
+    /** Registers this Bluetooth state change receiver. */
+    fun registerBluetoothStateReceiver() {
+        val bondStateFilter =
+            IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED).apply {
+                addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED)
+                addAction(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)
+            }
+        context.registerReceiver(
+            this,
+            bondStateFilter,
+            /* broadcastPermission= */ null,
+            /* scheduler= */ null
+        )
+    }
+
+    /** Unregisters this Bluetooth state change receiver. */
+    fun unregisterBluetoothStateReceiver() {
+        context.unregisterReceiver(this)
+    }
+
+    /** Clears current remote device. */
+    fun clearRemoteDevice() {
+        remoteDevice = null
+    }
+
+    /** Gets current remote device. */
+    fun getRemoteDevice(): BluetoothDevice? = remoteDevice
+
+    /** Gets current remote device as string. */
+    fun getRemoteDeviceAsString(): String = remoteDevice?.remoteDeviceToString() ?: "none"
+
+    /** Connects the Bluetooth A2DP sink profile service. */
+    fun connectA2DPSinkProfile() {
+        // Get the A2DP proxy before continuing with initialization.
+        bluetoothAdapter.getProfileProxy(
+            context,
+            object : BluetoothProfile.ServiceListener {
+                override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
+                    // When Bluetooth turns off and then on again, this is called again. But we only care
+                    // the first time. There doesn't seem to be a way to unregister our listener.
+                    if (a2dpSinkProxy == null) {
+                        a2dpSinkProxy = proxy
+                        listener.onA2DPSinkProfileConnected()
+                    }
+                }
+
+                override fun onServiceDisconnected(profile: Int) {}
+            },
+            BLUETOOTH_PROFILE_A2DP_SINK
+        )
+    }
+
+    /** Get the current Bluetooth scan mode of the local Bluetooth adapter. */
+    fun getScanMode(): Int = bluetoothAdapter.scanMode
+
+    /** Return true if the remote device is connected to the local adapter. */
+    fun isConnected(): Boolean = remoteDeviceConnectionState == BluetoothAdapter.STATE_CONNECTED
+
+    /** Return true if the remote device is bonded (paired) to the local adapter. */
+    fun isPaired(): Boolean = bluetoothAdapter.bondedDevices.contains(remoteDevice)
+
+    /** Gets the A2DP sink profile proxy. */
+    fun getA2DPSinkProfileProxy(): BluetoothProfile? = a2dpSinkProxy
+
+    /**
+     * Callback method for receiving Intent broadcast of Bluetooth state.
+     *
+     * See [BroadcastReceiver#onReceive].
+     *
+     * @param context the Context in which the receiver is running.
+     * @param intent the Intent being received.
+     */
+    override fun onReceive(context: Context, intent: Intent) {
+        when (intent.action) {
+            BluetoothDevice.ACTION_BOND_STATE_CHANGED -> {
+                // After a device starts bonding, we only pay attention to intents about that device.
+                val device =
+                    intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
+                val bondState =
+                    intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)
+                remoteDevice =
+                    when (bondState) {
+                        BluetoothDevice.BOND_BONDING, BluetoothDevice.BOND_BONDED -> device
+                        BluetoothDevice.BOND_NONE -> null
+                        else -> remoteDevice
+                    }
+                mLogger.log(
+                    "ACTION_BOND_STATE_CHANGED, the bound state of the remote device (%s) change to %s.",
+                    remoteDevice?.remoteDeviceToString(),
+                    bondState.bondStateToString()
+                )
+                listener.onBondStateChanged(bondState)
+            }
+            BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED -> {
+                remoteDeviceConnectionState =
+                    intent.getIntExtra(
+                        BluetoothAdapter.EXTRA_CONNECTION_STATE,
+                        BluetoothAdapter.STATE_DISCONNECTED
+                    )
+                mLogger.log(
+                    "ACTION_CONNECTION_STATE_CHANGED, the new connectionState: %s",
+                    remoteDeviceConnectionState
+                )
+                listener.onConnectionStateChanged(remoteDeviceConnectionState)
+            }
+            BluetoothAdapter.ACTION_SCAN_MODE_CHANGED -> {
+                val scanMode =
+                    intent.getIntExtra(
+                        BluetoothAdapter.EXTRA_SCAN_MODE,
+                        BluetoothAdapter.SCAN_MODE_NONE
+                    )
+                mLogger.log(
+                    "ACTION_SCAN_MODE_CHANGED, the new scanMode: %s",
+                    FastPairSimulator.scanModeToString(scanMode)
+                )
+                listener.onScanModeChange(scanMode)
+            }
+            else -> {}
+        }
+    }
+
+    private fun waitForBluetoothState(state: Int) {
+        while (bluetoothAdapter.state != state) {
+            SystemClock.sleep(1000)
+        }
+    }
+
+    private fun BluetoothDevice.remoteDeviceToString(): String = "${this.name}-${this.address}"
+
+    private fun Int.bondStateToString(): String =
+        when (this) {
+            BluetoothDevice.BOND_NONE -> "BOND_NONE"
+            BluetoothDevice.BOND_BONDING -> "BOND_BONDING"
+            BluetoothDevice.BOND_BONDED -> "BOND_BONDED"
+            else -> "BOND_ERROR"
+        }
+
+    /** Interface for listening the events from Bluetooth controller. */
+    interface EventListener {
+        /** The callback for the first onServiceConnected of A2DP sink profile. */
+        fun onA2DPSinkProfileConnected()
+
+        /**
+         * Reports the current bond state of the remote device.
+         *
+         * @param bondState the bond state of the remote device.
+         */
+        fun onBondStateChanged(bondState: Int)
+
+        /**
+         * Reports the current connection state of the remote device.
+         *
+         * @param connectionState the bond state of the remote device.
+         */
+        fun onConnectionStateChanged(connectionState: Int)
+
+        /**
+         * Reports the current scan mode of the local Adapter.
+         *
+         * @param mode the current scan mode of the local Adapter.
+         */
+        fun onScanModeChange(mode: Int)
+    }
+
+    companion object {
+        private const val TAG = "BluetoothController"
+
+        /** Hidden SystemApi field in [BluetoothProfile] interface. */
+        private const val BLUETOOTH_PROFILE_A2DP_SINK = 11
+
+        private const val TURN_AIRPLANE_MODE_OFF = 0
+        private const val TURN_AIRPLANE_MODE_ON = 1
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerConfig.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerConfig.java
new file mode 100644
index 0000000..3cacd55
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerConfig.java
@@ -0,0 +1,151 @@
+/*
+ * 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.fastpair.provider.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattService;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.bluetooth.BluetoothConsts;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.UUID;
+
+/** Configuration of a GATT server. */
+@TargetApi(18)
+public class BluetoothGattServerConfig {
+    private final Map<UUID, ServiceConfig> mServiceConfigs = new HashMap<UUID, ServiceConfig>();
+
+    @Nullable
+    private BluetoothGattServerHelper.Listener mServerlistener = null;
+
+    public BluetoothGattServerConfig addService(UUID uuid, ServiceConfig serviceConfig) {
+        mServiceConfigs.put(uuid, serviceConfig);
+        return this;
+    }
+
+    public BluetoothGattServerConfig setServerConnectionListener(
+            BluetoothGattServerHelper.Listener listener) {
+        mServerlistener = listener;
+        return this;
+    }
+
+    @Nullable
+    public BluetoothGattServerHelper.Listener getServerListener() {
+        return mServerlistener;
+    }
+
+    /**
+     * Adds a service and a characteristic to indicate that the server has dynamic services.
+     * This is a workaround for b/21587710.
+     * TODO(lingjunl): remove them when b/21587710 is fixed.
+     */
+    public BluetoothGattServerConfig addSelfDefinedDynamicService() {
+        ServiceConfig serviceConfig = new ServiceConfig().addCharacteristic(
+                new BluetoothGattServlet() {
+                    @Override
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                BluetoothConsts.SERVICE_DYNAMIC_CHARACTERISTIC,
+                                BluetoothGattCharacteristic.PROPERTY_READ,
+                                BluetoothGattCharacteristic.PERMISSION_READ);
+                    }
+                });
+        return addService(BluetoothConsts.SERVICE_DYNAMIC_SERVICE, serviceConfig);
+    }
+
+    public List<BluetoothGattService> getBluetoothGattServices() {
+        List<BluetoothGattService> result = new ArrayList<BluetoothGattService>();
+        for (Entry<UUID, ServiceConfig> serviceEntry : mServiceConfigs.entrySet()) {
+            UUID serviceUuid = serviceEntry.getKey();
+            ServiceConfig serviceConfig = serviceEntry.getValue();
+            if (serviceUuid == null || serviceConfig == null) {
+                // This is not supposed to happen
+                throw new IllegalStateException();
+            }
+            BluetoothGattService gattService = new BluetoothGattService(serviceUuid,
+                    BluetoothGattService.SERVICE_TYPE_PRIMARY);
+            for (Entry<BluetoothGattCharacteristic, BluetoothGattServlet> servletEntry :
+                    serviceConfig.getServlets().entrySet()) {
+                BluetoothGattCharacteristic characteristic = servletEntry.getKey();
+                if (characteristic == null) {
+                    // This is not supposed to happen
+                    throw new IllegalStateException();
+                }
+                gattService.addCharacteristic(characteristic);
+            }
+            result.add(gattService);
+        }
+        return result;
+    }
+
+    public List<UUID> getAdvertisedUuids() {
+        List<UUID> result = new ArrayList<UUID>();
+        for (Entry<UUID, ServiceConfig> serviceEntry : mServiceConfigs.entrySet()) {
+            UUID serviceUuid = serviceEntry.getKey();
+            ServiceConfig serviceConfig = serviceEntry.getValue();
+            if (serviceUuid == null || serviceConfig == null) {
+                // This is not supposed to happen
+                throw new IllegalStateException();
+            }
+            if (serviceConfig.isAdvertised()) {
+                result.add(serviceUuid);
+            }
+        }
+        return result;
+    }
+
+    public Map<BluetoothGattCharacteristic, BluetoothGattServlet> getServlets() {
+        Map<BluetoothGattCharacteristic, BluetoothGattServlet> result =
+                new HashMap<BluetoothGattCharacteristic, BluetoothGattServlet>();
+        for (ServiceConfig serviceConfig : mServiceConfigs.values()) {
+            result.putAll(serviceConfig.getServlets());
+        }
+        return result;
+    }
+
+    /** Configuration of a GATT service. */
+    public static class ServiceConfig {
+        private final Map<BluetoothGattCharacteristic, BluetoothGattServlet> mServlets =
+                new HashMap<BluetoothGattCharacteristic, BluetoothGattServlet>();
+        private boolean mAdvertise = false;
+
+        public ServiceConfig addCharacteristic(BluetoothGattServlet servlet) {
+            mServlets.put(servlet.getCharacteristic(), servlet);
+            return this;
+        }
+
+        public ServiceConfig setAdvertise(boolean advertise) {
+            mAdvertise = advertise;
+            return this;
+        }
+
+        public Map<BluetoothGattCharacteristic, BluetoothGattServlet> getServlets() {
+            return mServlets;
+        }
+
+        public boolean isAdvertised() {
+            return mAdvertise;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerConnection.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerConnection.java
new file mode 100644
index 0000000..fae6951
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerConnection.java
@@ -0,0 +1,468 @@
+/*
+ * 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.fastpair.provider.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.ReservedUuids;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Objects;
+import com.google.common.base.Preconditions;
+import com.google.common.io.BaseEncoding;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Connection to a bluetooth LE device over Gatt.
+ */
+@TargetApi(18)
+public class BluetoothGattServerConnection implements Closeable {
+    @SuppressWarnings("unused")
+    private static final String TAG = BluetoothGattServerConnection.class.getSimpleName();
+
+    /** See {@link BluetoothGattDescriptor#DISABLE_NOTIFICATION_VALUE}. */
+    private static final short DISABLE_NOTIFICATION_VALUE = 0x0000;
+
+    /** See {@link BluetoothGattDescriptor#ENABLE_NOTIFICATION_VALUE}. */
+    private static final short ENABLE_NOTIFICATION_VALUE = 0x0001;
+
+    /** See {@link BluetoothGattDescriptor#ENABLE_INDICATION_VALUE}. */
+    private static final short ENABLE_INDICATION_VALUE = 0x0002;
+
+    /** Default MTU when value is unknown. */
+    public static final int DEFAULT_MTU = 23;
+
+    @VisibleForTesting
+    static final long OPERATION_TIMEOUT = TimeUnit.SECONDS.toMillis(1);
+
+    /** Notification types as defined by the BLE spec vol 4, sec G, part 3.3.3.3 */
+    public enum NotificationType {
+        NOTIFICATION,
+        INDICATION
+    }
+
+    /** BT operation types that can be in flight. */
+    public enum OperationType {
+        SEND_NOTIFICATION
+    }
+
+    private final Map<ScopedKey, Object> mContextValues = new HashMap<ScopedKey, Object>();
+    private final List<Listener> mCloseListeners = new ArrayList<Listener>();
+
+    private final BluetoothGattServerHelper mBluetoothGattServerHelper;
+    private final BluetoothDevice mBluetoothDevice;
+
+    @VisibleForTesting
+    BluetoothOperationExecutor mBluetoothOperationScheduler =
+            new BluetoothOperationExecutor(1);
+
+    /** Stores pending writes. For each UUID, we store an offset and a byte[] of data. */
+    @VisibleForTesting
+    final Map<BluetoothGattServlet, SortedMap<Integer, byte[]>> mQueuedCharacteristicWrites =
+            new HashMap<BluetoothGattServlet, SortedMap<Integer, byte[]>>();
+
+    @VisibleForTesting
+    final Map<BluetoothGattCharacteristic, Notifier> mRegisteredNotifications =
+            new HashMap<BluetoothGattCharacteristic, Notifier>();
+
+    private final Map<BluetoothGattCharacteristic, BluetoothGattServlet> mServlets;
+
+    public BluetoothGattServerConnection(
+            BluetoothGattServerHelper bluetoothGattServerHelper,
+            BluetoothDevice device,
+            BluetoothGattServerConfig serverConfig) {
+        mBluetoothGattServerHelper = bluetoothGattServerHelper;
+        mBluetoothDevice = device;
+        mServlets = serverConfig.getServlets();
+    }
+
+    public void setContextValue(Object scope, String key, @Nullable Object value) {
+        mContextValues.put(new ScopedKey(scope, key), value);
+    }
+
+    @Nullable
+    public Object getContextValue(Object scope, String key) {
+        return mContextValues.get(new ScopedKey(scope, key));
+    }
+
+    public BluetoothDevice getDevice() {
+        return mBluetoothDevice;
+    }
+
+    public int getMtu() {
+        return DEFAULT_MTU;
+    }
+
+    public int getMaxDataPacketSize() {
+        // Per BT specs (3.2.9), only MTU - 3 bytes can be used to transmit data
+        return getMtu() - 3;
+    }
+
+    public void addCloseListener(Listener listener) {
+        synchronized (mCloseListeners) {
+            mCloseListeners.add(listener);
+        }
+    }
+
+    public void removeCloseListener(Listener listener) {
+        synchronized (mCloseListeners) {
+            mCloseListeners.remove(listener);
+        }
+    }
+
+    private BluetoothGattServlet getServlet(BluetoothGattCharacteristic characteristic)
+            throws BluetoothGattException {
+        BluetoothGattServlet servlet = mServlets.get(characteristic);
+        if (servlet == null) {
+            throw new BluetoothGattException(
+                    String.format("No handler registered for characteristic %s.",
+                            characteristic.getUuid()),
+                    BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+        }
+        return servlet;
+    }
+
+    public byte[] readCharacteristic(int offset, BluetoothGattCharacteristic characteristic)
+            throws BluetoothGattException {
+        return getServlet(characteristic).read(this, offset);
+    }
+
+    public void writeCharacteristic(BluetoothGattCharacteristic characteristic,
+            boolean preparedWrite,
+            int offset, byte[] value) throws BluetoothGattException {
+        Log.d(TAG, String.format(
+                "Received %d bytes at offset %d on %s from device %s, prepareWrite=%s.",
+                value.length,
+                offset,
+                BluetoothGattUtils.toString(characteristic),
+                mBluetoothDevice,
+                preparedWrite));
+        BluetoothGattServlet servlet = getServlet(characteristic);
+        if (preparedWrite) {
+            SortedMap<Integer, byte[]> bytePackets = mQueuedCharacteristicWrites.get(servlet);
+            if (bytePackets == null) {
+                bytePackets = new TreeMap<Integer, byte[]>();
+                mQueuedCharacteristicWrites.put(servlet, bytePackets);
+            }
+            bytePackets.put(offset, value);
+            return;
+        }
+
+        Log.d(TAG, servlet.toString());
+        servlet.write(this, offset, value);
+    }
+
+    public byte[] readDescriptor(int offset, BluetoothGattDescriptor descriptor)
+            throws BluetoothGattException {
+        BluetoothGattCharacteristic characteristic = descriptor.getCharacteristic();
+        if (characteristic == null) {
+            throw new BluetoothGattException(String.format(
+                    "Descriptor %s not associated with a characteristics!",
+                    BluetoothGattUtils.toString(descriptor)), BluetoothGatt.GATT_FAILURE);
+        }
+        return getServlet(characteristic).readDescriptor(this, descriptor, offset);
+    }
+
+    public void writeDescriptor(
+            BluetoothGattDescriptor descriptor,
+            boolean preparedWrite,
+            int offset,
+            byte[] value) throws BluetoothGattException {
+        Log.d(TAG, String.format(
+                "Received %d bytes at offset %d on %s from device %s, prepareWrite=%s.",
+                value.length,
+                offset,
+                BluetoothGattUtils.toString(descriptor),
+                mBluetoothDevice,
+                preparedWrite));
+        if (preparedWrite) {
+            throw new BluetoothGattException(
+                    String.format("Prepare write not supported for descriptor %s.", descriptor),
+                    BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+        }
+
+        BluetoothGattCharacteristic characteristic = descriptor.getCharacteristic();
+        if (characteristic == null) {
+            throw new BluetoothGattException(String.format(
+                    "Descriptor %s not associated with a characteristics!",
+                    BluetoothGattUtils.toString(descriptor)), BluetoothGatt.GATT_FAILURE);
+        }
+        BluetoothGattServlet servlet = getServlet(characteristic);
+        if (descriptor.getUuid().equals(
+                ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION)) {
+            handleCharacteristicConfigurationChange(characteristic, servlet, offset, value);
+            return;
+        }
+        servlet.writeDescriptor(this, descriptor, offset, value);
+    }
+
+    private void handleCharacteristicConfigurationChange(
+            final BluetoothGattCharacteristic characteristic, BluetoothGattServlet servlet,
+            int offset,
+            byte[] value)
+            throws BluetoothGattException {
+        if (offset != 0) {
+            throw new BluetoothGattException(String.format(
+                    "Offset should be 0 when changing the client characteristic config: %d.",
+                    offset),
+                    BluetoothGatt.GATT_INVALID_OFFSET);
+        }
+        if (value.length != 2) {
+            throw new BluetoothGattException(String.format(
+                    "Value 0x%s is undefined for the client characteristic config",
+                    BaseEncoding.base16().encode(value)),
+                    BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH);
+        }
+
+        boolean notificationRegistered = mRegisteredNotifications.containsKey(characteristic);
+        Notifier notifier;
+        switch (toShort(value)) {
+            case ENABLE_NOTIFICATION_VALUE:
+                if (!notificationRegistered) {
+                    notifier = new Notifier() {
+                        @Override
+                        public void notify(byte[] data) throws BluetoothException {
+                            sendNotification(characteristic, NotificationType.NOTIFICATION, data);
+                        }
+                    };
+                    mRegisteredNotifications.put(characteristic, notifier);
+                    servlet.enableNotification(this, notifier);
+                }
+                break;
+            case ENABLE_INDICATION_VALUE:
+                if (!notificationRegistered) {
+                    notifier = new Notifier() {
+                        @Override
+                        public void notify(byte[] data) throws BluetoothException {
+                            sendNotification(characteristic, NotificationType.INDICATION, data);
+                        }
+                    };
+                    mRegisteredNotifications.put(characteristic, notifier);
+                    servlet.enableNotification(this, notifier);
+                }
+                break;
+            case DISABLE_NOTIFICATION_VALUE:
+                // Note: this disables notifications or indications.
+                if (notificationRegistered) {
+                    notifier = mRegisteredNotifications.remove(characteristic);
+                    if (notifier == null) {
+                        return; // this is not supposed to happen
+                    }
+                    servlet.disableNotification(this, notifier);
+                }
+                break;
+            default:
+                throw new BluetoothGattException(String.format(
+                        "Value 0x%s is undefined for the client characteristic config",
+                        BaseEncoding.base16().encode(value)),
+                        BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+        }
+    }
+
+    private static short toShort(byte[] value) {
+        Preconditions.checkNotNull(value);
+        Preconditions.checkArgument(value.length == 2, "Length should be 2 bytes.");
+
+        return (short) ((value[0] & 0x00FF) | (value[1] << 8));
+    }
+
+    public void executeWrite(boolean execute) throws BluetoothGattException {
+        if (!execute) {
+            mQueuedCharacteristicWrites.clear();
+            return;
+        }
+
+        try {
+            for (Entry<BluetoothGattServlet, SortedMap<Integer, byte[]>> queuedWrite :
+                    mQueuedCharacteristicWrites.entrySet()) {
+                BluetoothGattServlet servlet = queuedWrite.getKey();
+                SortedMap<Integer, byte[]> chunks = queuedWrite.getValue();
+                if (servlet == null || chunks == null) {
+                    // This is not supposed to happen
+                    throw new IllegalStateException();
+                }
+                assembleByteChunksAndHandle(servlet, chunks);
+            }
+        } finally {
+            mQueuedCharacteristicWrites.clear();
+        }
+    }
+
+    /**
+     * Assembles the specified queued writes and calls the provided write handler on the assembled
+     * chunks. Tries to assemble all the chunks into one write request. For example, if the content
+     * of byteChunks is:
+     * <code>
+     * offset data_size
+     * 0       10
+     * 10        1
+     * 11        5
+     * </code>
+     *
+     * then this method would call <code>writeHandler.onWrite(0, byte[16])</code>
+     *
+     * However, if all the chunks cannot be assembled into a continuous byte[], then onWrite() will
+     * be called multiple times with the largest continuous chunks. For example, if the content of
+     * byteChunks is:
+     * <code>
+     * offset data_size
+     * 10       12
+     * 30        5
+     * 35        9
+     * </code>
+     *
+     * then this method would call <code>writeHandler.onWrite(10, byte[12)</code> and
+     * <code>writeHandler.onWrite(30, byte[14]).
+     */
+    private void assembleByteChunksAndHandle(BluetoothGattServlet servlet,
+            SortedMap<Integer, byte[]> byteChunks) throws BluetoothGattException {
+        ByteArrayOutputStream assembledRequest = new ByteArrayOutputStream();
+        Integer startWritingAtOffset = 0;
+
+        while (!byteChunks.isEmpty()) {
+            Integer offset = byteChunks.firstKey();
+
+            if (offset.intValue() < startWritingAtOffset + assembledRequest.size()) {
+                throw new BluetoothGattException(
+                        "Expected offset of at least " + assembledRequest.size()
+                                + ", but got offset " + offset, BluetoothGatt.GATT_INVALID_OFFSET);
+            }
+
+            // If we have a hole, then write what we've already assembled and start assembling a new
+            // long write
+            if (offset.intValue() > startWritingAtOffset + assembledRequest.size()) {
+                servlet.write(this, startWritingAtOffset.intValue(),
+                        assembledRequest.toByteArray());
+                startWritingAtOffset = offset;
+                assembledRequest.reset();
+            }
+
+            try {
+                byte[] dataChunk = byteChunks.remove(offset);
+                if (dataChunk == null) {
+                    // This is not supposed to happen
+                    throw new IllegalStateException();
+                }
+                assembledRequest.write(dataChunk);
+            } catch (IOException e) {
+                throw new BluetoothGattException("Error assembling request",
+                        BluetoothGatt.GATT_FAILURE);
+            }
+        }
+
+        // If there is anything to write, write it
+        if (assembledRequest.size() > 0) {
+            Preconditions.checkNotNull(startWritingAtOffset); // should never be null at this point
+            servlet.write(this, startWritingAtOffset.intValue(), assembledRequest.toByteArray());
+        }
+    }
+
+    private void sendNotification(final BluetoothGattCharacteristic characteristic,
+            final NotificationType notificationType, final byte[] data)
+            throws BluetoothException {
+        mBluetoothOperationScheduler.execute(
+                new Operation<Void>(OperationType.SEND_NOTIFICATION) {
+                    @Override
+                    public void run() throws BluetoothException {
+                        mBluetoothGattServerHelper.sendNotification(mBluetoothDevice,
+                                characteristic,
+                                data,
+                                notificationType == NotificationType.INDICATION ? true : false);
+                    }
+                },
+                OPERATION_TIMEOUT);
+    }
+
+    @Override
+    public void close() throws IOException {
+        try {
+            mBluetoothGattServerHelper.closeConnection(mBluetoothDevice);
+        } catch (BluetoothException e) {
+            throw new IOException("Failed to close connection", e);
+        }
+    }
+
+    public void notifyNotificationSent(int status) {
+        mBluetoothOperationScheduler.notifyCompletion(
+                new Operation<Void>(OperationType.SEND_NOTIFICATION), status);
+    }
+
+    public void onClose() {
+        synchronized (mCloseListeners) {
+            for (Listener listener : mCloseListeners) {
+                listener.onClose();
+            }
+        }
+    }
+
+    /** Scope/key pair to use to reference contextual values. */
+    private static class ScopedKey {
+        private final Object mScope;
+        private final String mKey;
+
+        ScopedKey(Object scope, String key) {
+            mScope = scope;
+            mKey = key;
+        }
+
+        @Override
+        public boolean equals(@Nullable Object o) {
+            if (!(o instanceof ScopedKey)) {
+                return false;
+            }
+            ScopedKey other = (ScopedKey) o;
+            return other.mScope.equals(mScope) && other.mKey.equals(mKey);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(mScope, mKey);
+        }
+    }
+
+    /** Listener to be notified when the connection closes. */
+    public interface Listener {
+        void onClose();
+    }
+
+    /** Notifier to notify data over notification or indication. */
+    public interface Notifier {
+        void notify(byte[] data) throws BluetoothException;
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerHelper.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerHelper.java
new file mode 100644
index 0000000..9339e14
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServerHelper.java
@@ -0,0 +1,449 @@
+/*
+ * 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.fastpair.provider.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.testability.VersionProvider;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattServer;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattServerCallback;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+
+import com.google.common.base.Preconditions;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helper for simplifying operations on {@link BluetoothGattServer}.
+ */
+@TargetApi(18)
+public class BluetoothGattServerHelper {
+    private static final String TAG = BluetoothGattServerHelper.class.getSimpleName();
+
+    @VisibleForTesting
+    static final long OPERATION_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(1);
+    private static final int MAX_PARALLEL_OPERATIONS = 5;
+
+    /** BT operation types that can be in flight. */
+    public enum OperationType {
+        ADD_SERVICE,
+        CLOSE_CONNECTION,
+        START_ADVERTISING
+    }
+
+    private final Object mOperationLock = new Object();
+    @VisibleForTesting
+    final BluetoothGattServerCallback mGattServerCallback =
+            new GattServerCallback();
+    @VisibleForTesting
+    BluetoothOperationExecutor mBluetoothOperationScheduler =
+            new BluetoothOperationExecutor(MAX_PARALLEL_OPERATIONS);
+
+    private final Context mContext;
+    private final BluetoothManager mBluetoothManager;
+    private final VersionProvider mVersionProvider;
+
+    @Nullable
+    @VisibleForTesting
+    volatile BluetoothGattServerConfig mServerConfig = null;
+
+    @Nullable
+    @VisibleForTesting
+    volatile BluetoothGattServer mBluetoothGattServer = null;
+
+    @VisibleForTesting
+    final ConcurrentMap<BluetoothDevice, BluetoothGattServerConnection>
+            mConnections = new ConcurrentHashMap<BluetoothDevice, BluetoothGattServerConnection>();
+
+    public BluetoothGattServerHelper(Context context, BluetoothManager bluetoothManager) {
+        this(
+                Preconditions.checkNotNull(context),
+                Preconditions.checkNotNull(bluetoothManager),
+                new VersionProvider()
+        );
+    }
+
+    @VisibleForTesting
+    BluetoothGattServerHelper(
+            Context context, BluetoothManager bluetoothManager, VersionProvider versionProvider) {
+        mContext = context;
+        mBluetoothManager = bluetoothManager;
+        mVersionProvider = versionProvider;
+    }
+
+    @Nullable
+    public BluetoothGattServerConfig getConfig() {
+        return mServerConfig;
+    }
+
+    public void open(final BluetoothGattServerConfig gattServerConfig) throws BluetoothException {
+        synchronized (mOperationLock) {
+            Preconditions.checkState(mBluetoothGattServer == null, "Gatt server is already open.");
+            final BluetoothGattServer server =
+                    mBluetoothManager.openGattServer(mContext, mGattServerCallback);
+            if (server == null) {
+                throw new BluetoothException(
+                        "Failed to open the GATT server, openGattServer returned null.");
+            }
+
+            try {
+                for (final BluetoothGattService service :
+                        gattServerConfig.getBluetoothGattServices()) {
+                    if (service == null) {
+                        continue;
+                    }
+                    mBluetoothOperationScheduler.execute(
+                            new Operation<Void>(OperationType.ADD_SERVICE, service) {
+                                @Override
+                                public void run() throws BluetoothException {
+                                    boolean success = server.addService(service);
+                                    if (!success) {
+                                        throw new BluetoothException("Fails on adding service");
+                                    }
+                                }
+                            }, OPERATION_TIMEOUT_MILLIS);
+                }
+                mBluetoothGattServer = server;
+                mServerConfig = gattServerConfig;
+            } catch (BluetoothException e) {
+                server.close();
+                throw e;
+            }
+        }
+    }
+
+    public boolean isOpen() {
+        synchronized (mOperationLock) {
+            return mBluetoothGattServer != null;
+        }
+    }
+
+    public void close() {
+        synchronized (mOperationLock) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            bluetoothGattServer.close();
+            mBluetoothGattServer = null;
+        }
+    }
+
+    private BluetoothGattServerConnection getConnectionByDevice(BluetoothDevice device)
+            throws BluetoothGattException {
+        BluetoothGattServerConnection bluetoothLeConnection = mConnections.get(device);
+        if (bluetoothLeConnection == null) {
+            throw new BluetoothGattException(
+                    String.format("Received operation on an unknown device: %s", device),
+                    BluetoothGatt.GATT_FAILURE);
+        }
+        return bluetoothLeConnection;
+    }
+
+    public void sendNotification(
+            BluetoothDevice device,
+            BluetoothGattCharacteristic characteristic,
+            byte[] data,
+            boolean confirm)
+            throws BluetoothException {
+        Log.d(TAG, String.format("Sending a %s of %d bytes on characteristics %s on device %s.",
+                confirm ? "indication" : "notification",
+                data.length,
+                characteristic.getUuid(),
+                device));
+        synchronized (mOperationLock) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                throw new BluetoothException("Server is not open.");
+            }
+            BluetoothGattCharacteristic clonedCharacteristic =
+                    BluetoothGattUtils.clone(characteristic);
+            clonedCharacteristic.setValue(data);
+            bluetoothGattServer.notifyCharacteristicChanged(device, clonedCharacteristic, confirm);
+        }
+    }
+
+    public void closeConnection(final BluetoothDevice bluetoothDevice) throws BluetoothException {
+        final BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+        if (bluetoothGattServer == null) {
+            throw new BluetoothException("Server is not open.");
+        }
+        int connectionSate =
+                mBluetoothManager.getConnectionState(bluetoothDevice, BluetoothProfile.GATT);
+        if (connectionSate != BluetoothGatt.STATE_CONNECTED) {
+            return;
+        }
+        mBluetoothOperationScheduler.execute(
+                new Operation<Void>(OperationType.CLOSE_CONNECTION) {
+                    @Override
+                    public void run() throws BluetoothException {
+                        bluetoothGattServer.cancelConnection(bluetoothDevice);
+                    }
+                },
+                OPERATION_TIMEOUT_MILLIS);
+    }
+
+    private class GattServerCallback extends BluetoothGattServerCallback {
+        @Override
+        public void onServiceAdded(int status, BluetoothGattService service) {
+            mBluetoothOperationScheduler.notifyCompletion(
+                    new Operation<Void>(OperationType.ADD_SERVICE, service), status);
+        }
+
+        @Override
+        public void onConnectionStateChange(BluetoothDevice device, int status, int newState) {
+            BluetoothGattServerConfig serverConfig = mServerConfig;
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            BluetoothGattServerConnection bluetoothLeConnection;
+            if (serverConfig == null || bluetoothGattServer == null) {
+                return;
+            }
+            switch (newState) {
+                case BluetoothGattServer.STATE_CONNECTED:
+                    if (status != BluetoothGatt.GATT_SUCCESS) {
+                        Log.e(TAG, String.format("Connection to %s failed: %s", device,
+                                BluetoothGattUtils.getMessageForStatusCode(status)));
+                        return;
+                    }
+                    Log.i(TAG, String.format("Connected to device %s.", device));
+                    if (mConnections.containsKey(device)) {
+                        Log.w(TAG, String.format("A connection is already open with device %s. "
+                                + "Keeping existing one.", device));
+                        return;
+                    }
+
+                    BluetoothGattServerConnection connection = new BluetoothGattServerConnection(
+                            BluetoothGattServerHelper.this,
+                            device,
+                            serverConfig);
+                    if (serverConfig.getServerListener() != null) {
+                        serverConfig.getServerListener().onConnection(connection);
+                    }
+                    mConnections.put(device, connection);
+
+                    // By default, Android disconnects active GATT server connection if the
+                    // advertisement is
+                    // stop (or sometime stopScanning also disconnect, see b/62667394). Asking
+                    // the server to
+                    // reverse connect will tell Android to keep the connection open.
+                    // Code handling connect() on Android OS is: btif_gatt_server.c
+                    // Note: for Android < P, unknown type devices don't connect. See b/62827460.
+                    //       for Android P+, unknown type devices always use LE to connect (see
+                    //       code)
+                    // Note: for Android < N, dual mode devices always connect using BT classic,
+                    // so connect()
+                    //       should *NOT* be called for those devices. See b/29819614.
+                    if (mVersionProvider.getSdkInt() >= VERSION_CODES.N
+                            || device.getType() != BluetoothDevice.DEVICE_TYPE_DUAL) {
+                        boolean success = bluetoothGattServer.connect(device, /* autoConnect */
+                                false);
+                        if (!success) {
+                            Log.w(TAG, String.format(
+                                    "Keeping connection open on stop advertising failed for "
+                                            + "device %s.",
+                                    device));
+                        }
+                    }
+                    break;
+                case BluetoothGattServer.STATE_DISCONNECTED:
+                    if (status != BluetoothGatt.GATT_SUCCESS) {
+                        Log.w(TAG, String.format(
+                                "Disconnection from %s error: %s. Proceeding anyway.",
+                                device, BluetoothGattUtils.getMessageForStatusCode(status)));
+                    }
+                    bluetoothLeConnection = mConnections.remove(device);
+                    if (bluetoothLeConnection != null) {
+                        // Disconnect the server, required after connecting to it.
+                        bluetoothGattServer.cancelConnection(device);
+                        bluetoothLeConnection.onClose();
+                    }
+                    mBluetoothOperationScheduler.notifyCompletion(
+                            new Operation<Void>(OperationType.CLOSE_CONNECTION), status);
+                    break;
+                default:
+                    Log.e(TAG, String.format("Unexpected connection state: %d", newState));
+            }
+        }
+
+        @Override
+        public void onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset,
+                BluetoothGattCharacteristic characteristic) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                byte[] value =
+                        getConnectionByDevice(device).readCharacteristic(offset, characteristic);
+                bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS,
+                        offset,
+                        value);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG,
+                        String.format(
+                                "Could not read  %s on device %s at offset %d",
+                                BluetoothGattUtils.toString(characteristic),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(
+                        device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onCharacteristicWriteRequest(BluetoothDevice device,
+                int requestId,
+                BluetoothGattCharacteristic characteristic,
+                boolean preparedWrite,
+                boolean responseNeeded,
+                int offset,
+                byte[] value) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                getConnectionByDevice(device).writeCharacteristic(characteristic,
+                        preparedWrite,
+                        offset,
+                        value);
+                if (responseNeeded) {
+                    bluetoothGattServer.sendResponse(
+                            device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null);
+                }
+            } catch (BluetoothGattException e) {
+                Log.e(TAG,
+                        String.format(
+                                "Could not write %s on device %s at offset %d",
+                                BluetoothGattUtils.toString(characteristic),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(
+                        device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset,
+                BluetoothGattDescriptor descriptor) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                byte[] value = getConnectionByDevice(device).readDescriptor(offset, descriptor);
+                bluetoothGattServer.sendResponse(
+                        device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG, String.format(
+                                "Could not read %s on device %s at %d",
+                                BluetoothGattUtils.toString(descriptor),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(
+                        device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onDescriptorWriteRequest(BluetoothDevice device,
+                int requestId,
+                BluetoothGattDescriptor descriptor,
+                boolean preparedWrite,
+                boolean responseNeeded,
+                int offset,
+                byte[] value) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                getConnectionByDevice(device)
+                        .writeDescriptor(descriptor, preparedWrite, offset, value);
+                if (responseNeeded) {
+                    bluetoothGattServer.sendResponse(
+                            device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null);
+                }
+                Log.d(TAG, "Operation onDescriptorWriteRequest successful.");
+            } catch (BluetoothGattException e) {
+                Log.e(TAG,
+                        String.format(
+                                "Could not write %s on device %s at %d",
+                                BluetoothGattUtils.toString(descriptor),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(
+                        device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                getConnectionByDevice(device).executeWrite(execute);
+                bluetoothGattServer.sendResponse(
+                        device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG, "Could not execute write.", e);
+                bluetoothGattServer.sendResponse(device, requestId, e.getGattErrorCode(), 0, null);
+            }
+        }
+
+        @Override
+        public void onNotificationSent(BluetoothDevice device, int status) {
+            Log.d(TAG,
+                    String.format("Received onNotificationSent for device %s with status %s",
+                            device, status));
+            try {
+                getConnectionByDevice(device).notifyNotificationSent(status);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG, "An error occurred when receiving onNotificationSent: " + e);
+            }
+        }
+    }
+
+    /** Listener for {@link BluetoothGattServerHelper}'s events. */
+    public interface Listener {
+        /** Called when a new connection to the server is established. */
+        void onConnection(BluetoothGattServerConnection connection);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServlet.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServlet.java
new file mode 100644
index 0000000..e25e223
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattServlet.java
@@ -0,0 +1,71 @@
+/*
+ * 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.fastpair.provider.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.nearby.fastpair.provider.bluetooth.BluetoothGattServerConnection.Notifier;
+
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+
+/** Servlet to handle GATT operations on a characteristic. */
+@TargetApi(18)
+public abstract class BluetoothGattServlet {
+    public byte[] read(BluetoothGattServerConnection connection,
+            @SuppressWarnings("unused") int offset) throws BluetoothGattException {
+        throw new BluetoothGattException("Read not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void write(BluetoothGattServerConnection connection,
+            @SuppressWarnings("unused") int offset, @SuppressWarnings("unused") byte[] value)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Write not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public byte[] readDescriptor(BluetoothGattServerConnection connection,
+            BluetoothGattDescriptor descriptor, @SuppressWarnings("unused") int offset)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Read not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void writeDescriptor(BluetoothGattServerConnection connection,
+            BluetoothGattDescriptor descriptor,
+            @SuppressWarnings("unused") int offset, @SuppressWarnings("unused") byte[] value)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Write not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void enableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Notification not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void disableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Notification not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public abstract BluetoothGattCharacteristic getCharacteristic();
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattUtils.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattUtils.java
new file mode 100644
index 0000000..7ac26ee
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothGattUtils.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 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 android.nearby.fastpair.provider.bluetooth;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+
+/**
+ * Utils for Gatt profile.
+ */
+public class BluetoothGattUtils {
+
+    /**
+     * Returns a string message for a BluetoothGatt status codes.
+     */
+    public static String getMessageForStatusCode(int statusCode) {
+        switch (statusCode) {
+            case BluetoothGatt.GATT_SUCCESS:
+                return "GATT_SUCCESS";
+            case BluetoothGatt.GATT_FAILURE:
+                return "GATT_FAILURE";
+            case BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION:
+                return "GATT_INSUFFICIENT_AUTHENTICATION";
+            case BluetoothGatt.GATT_INSUFFICIENT_AUTHORIZATION:
+                return "GATT_INSUFFICIENT_AUTHORIZATION";
+            case BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION:
+                return "GATT_INSUFFICIENT_ENCRYPTION";
+            case BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH:
+                return "GATT_INVALID_ATTRIBUTE_LENGTH";
+            case BluetoothGatt.GATT_INVALID_OFFSET:
+                return "GATT_INVALID_OFFSET";
+            case BluetoothGatt.GATT_READ_NOT_PERMITTED:
+                return "GATT_READ_NOT_PERMITTED";
+            case BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED:
+                return "GATT_REQUEST_NOT_SUPPORTED";
+            case BluetoothGatt.GATT_WRITE_NOT_PERMITTED:
+                return "GATT_WRITE_NOT_PERMITTED";
+            case BluetoothGatt.GATT_CONNECTION_CONGESTED:
+                return "GATT_CONNECTION_CONGESTED";
+            default:
+                return "Unknown error code";
+        }
+    }
+
+    /** Clones a {@link BluetoothGattCharacteristic} so the value can be changed thread-safely. */
+    public static BluetoothGattCharacteristic clone(BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        BluetoothGattCharacteristic result = new BluetoothGattCharacteristic(
+                characteristic.getUuid(),
+                characteristic.getProperties(), characteristic.getPermissions());
+        try {
+            Field instanceIdField = BluetoothGattCharacteristic.class.getDeclaredField("mInstance");
+            Field serviceField = BluetoothGattCharacteristic.class.getDeclaredField("mService");
+            Field descriptorField = BluetoothGattCharacteristic.class.getDeclaredField(
+                    "mDescriptors");
+            instanceIdField.setAccessible(true);
+            serviceField.setAccessible(true);
+            descriptorField.setAccessible(true);
+            instanceIdField.set(result, instanceIdField.get(characteristic));
+            serviceField.set(result, serviceField.get(characteristic));
+            descriptorField.set(result, descriptorField.get(characteristic));
+            byte[] value = characteristic.getValue();
+            if (value != null) {
+                result.setValue(Arrays.copyOf(value, value.length));
+            }
+            result.setWriteType(characteristic.getWriteType());
+        } catch (NoSuchFieldException e) {
+            throw new BluetoothException("Cannot clone characteristic.", e);
+        } catch (IllegalAccessException e) {
+            throw new BluetoothException("Cannot clone characteristic.", e);
+        } catch (IllegalArgumentException e) {
+            throw new BluetoothException("Cannot clone characteristic.", e);
+        }
+        return result;
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattDescriptor}. */
+    public static String toString(@Nullable BluetoothGattDescriptor descriptor) {
+        if (descriptor == null) {
+            return "null descriptor";
+        }
+        return String.format("descriptor %s on %s",
+                descriptor.getUuid(),
+                toString(descriptor.getCharacteristic()));
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattCharacteristic}. */
+    public static String toString(@Nullable BluetoothGattCharacteristic characteristic) {
+        if (characteristic == null) {
+            return "null characteristic";
+        }
+        return String.format("characteristic %s on %s",
+                characteristic.getUuid(),
+                toString(characteristic.getService()));
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattService}. */
+    public static String toString(@Nullable BluetoothGattService service) {
+        if (service == null) {
+            return "null service";
+        }
+        return String.format("service %s", service.getUuid());
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothManager.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothManager.java
new file mode 100644
index 0000000..bf241f1
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/BluetoothManager.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 android.nearby.fastpair.provider.bluetooth;
+
+import android.content.Context;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattServer;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattServerCallback;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.BluetoothManager}.
+ */
+public class BluetoothManager {
+
+    private android.bluetooth.BluetoothManager mWrappedInstance;
+
+    private BluetoothManager(android.bluetooth.BluetoothManager instance) {
+        mWrappedInstance = instance;
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothManager#openGattServer(Context,
+     * android.bluetooth.BluetoothGattServerCallback)}.
+     */
+    @Nullable
+    public BluetoothGattServer openGattServer(Context context,
+            BluetoothGattServerCallback callback) {
+        return BluetoothGattServer.wrap(
+                mWrappedInstance.openGattServer(context, callback.unwrap()));
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothManager#getConnectionState(
+     *android.bluetooth.BluetoothDevice, int)}.
+     */
+    public int getConnectionState(BluetoothDevice device, int profile) {
+        return mWrappedInstance.getConnectionState(device.unwrap(), profile);
+    }
+
+    /** See {@link android.bluetooth.BluetoothManager#getConnectedDevices(int)}. */
+    public List<BluetoothDevice> getConnectedDevices(int profile) {
+        List<android.bluetooth.BluetoothDevice> devices = mWrappedInstance.getConnectedDevices(
+                profile);
+        List<BluetoothDevice> wrappedDevices = new ArrayList<>(devices.size());
+        for (android.bluetooth.BluetoothDevice device : devices) {
+            wrappedDevices.add(BluetoothDevice.wrap(device));
+        }
+        return wrappedDevices;
+    }
+
+    /** See {@link android.bluetooth.BluetoothManager#getAdapter()}. */
+    public BluetoothAdapter getAdapter() {
+        return BluetoothAdapter.wrap(mWrappedInstance.getAdapter());
+    }
+
+    public static BluetoothManager wrap(android.bluetooth.BluetoothManager bluetoothManager) {
+        return new BluetoothManager(bluetoothManager);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/RfcommServer.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/RfcommServer.java
new file mode 100644
index 0000000..9ed95ac
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/bluetooth/RfcommServer.java
@@ -0,0 +1,419 @@
+/*
+ * 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.fastpair.provider.bluetooth;
+
+import static android.nearby.fastpair.provider.bluetooth.RfcommServer.State.ACCEPTING;
+import static android.nearby.fastpair.provider.bluetooth.RfcommServer.State.CONNECTED;
+import static android.nearby.fastpair.provider.bluetooth.RfcommServer.State.RESTARTING;
+import static android.nearby.fastpair.provider.bluetooth.RfcommServer.State.STARTING;
+import static android.nearby.fastpair.provider.bluetooth.RfcommServer.State.STOPPED;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.nearby.fastpair.provider.EventStreamProtocol;
+import android.nearby.fastpair.provider.utils.Logger;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Listens for a rfcomm client to connect and supports both sending messages to the client and
+ * receiving messages from the client.
+ */
+public class RfcommServer {
+    private static final String TAG = "RfcommServer";
+    private final Logger mLogger = new Logger(TAG);
+
+    private static final String FAST_PAIR_RFCOMM_SERVICE_NAME = "FastPairServer";
+    public static final UUID FAST_PAIR_RFCOMM_UUID =
+            UUID.fromString("df21fe2c-2515-4fdb-8886-f12c4d67927c");
+
+    /** A single thread executor where all state checks are performed. */
+    private final ExecutorService mControllerExecutor = Executors.newSingleThreadExecutor();
+
+    private final ExecutorService mSendMessageExecutor = Executors.newSingleThreadExecutor();
+    private final ExecutorService mReceiveMessageExecutor = Executors.newSingleThreadExecutor();
+
+    @Nullable
+    private BluetoothServerSocket mServerSocket;
+    @Nullable
+    private BluetoothSocket mSocket;
+
+    private State mState = STOPPED;
+    private boolean mIsStopRequested = false;
+
+    @Nullable
+    private RequestHandler mRequestHandler;
+
+    @Nullable
+    private CountDownLatch mCountDownLatch;
+    @Nullable
+    private StateMonitor mStateMonitor;
+
+    /**
+     * Manages RfcommServer status.
+     *
+     * <pre>{@code
+     *      +------------------------------------------------+
+     *      +-------------------------------+                |
+     *      v                               |                |
+     * +---------+    +----------+    +-----+-----+    +-----+-----+
+     * | STOPPED +--> | STARTING +--> | ACCEPTING +--> | CONNECTED |
+     * +---------+    +-----+----+    +-------+---+    +-----+-----+
+     *      ^               |             ^   v              |
+     *      +---------------+         +---+--------+         |
+     *                                | RESTARTING | <-------+
+     *                                +------------+
+     * }</pre>
+     *
+     * If Stop action is not requested, the server will restart forever. Otherwise, go stopped.
+     */
+    public enum State {
+        STOPPED,
+        STARTING,
+        RESTARTING,
+        ACCEPTING,
+        CONNECTED,
+    }
+
+    /** Starts the rfcomm server. */
+    public void start() {
+        runInControllerExecutor(this::startServer);
+    }
+
+    private void startServer() {
+        log("Start RfcommServer");
+
+        if (!mState.equals(STOPPED)) {
+            log("Server is not stopped, skip start request.");
+            return;
+        }
+        updateState(STARTING);
+        mIsStopRequested = false;
+
+        startAccept();
+    }
+
+    private void restartServer() {
+        log("Restart RfcommServer");
+        updateState(RESTARTING);
+        startAccept();
+    }
+
+    private void startAccept() {
+        try {
+            // Gets server socket in controller thread for stop() API.
+            mServerSocket =
+                    BluetoothAdapter.getDefaultAdapter()
+                            .listenUsingRfcommWithServiceRecord(
+                                    FAST_PAIR_RFCOMM_SERVICE_NAME, FAST_PAIR_RFCOMM_UUID);
+        } catch (IOException e) {
+            log("Create service record failed, stop server");
+            stopServer();
+            return;
+        }
+
+        updateState(ACCEPTING);
+        new Thread(() -> accept(mServerSocket)).start();
+    }
+
+    private void accept(BluetoothServerSocket serverSocket) {
+        triggerCountdownLatch();
+
+        try {
+            BluetoothSocket socket = serverSocket.accept();
+            serverSocket.close();
+
+            runInControllerExecutor(() -> startListen(socket));
+        } catch (IOException e) {
+            log("IOException when accepting new connection");
+            runInControllerExecutor(() -> handleAcceptException(serverSocket));
+        }
+    }
+
+    private void handleAcceptException(BluetoothServerSocket serverSocket) {
+        if (mIsStopRequested) {
+            stopServer();
+        } else {
+            closeServerSocket(serverSocket);
+            restartServer();
+        }
+    }
+
+    private void startListen(BluetoothSocket bluetoothSocket) {
+        if (mIsStopRequested) {
+            closeSocket(bluetoothSocket);
+            stopServer();
+            return;
+        }
+
+        updateState(CONNECTED);
+        // Sets method parameter to global socket for stop() API.
+        this.mSocket = bluetoothSocket;
+        new Thread(() -> listen(bluetoothSocket)).start();
+    }
+
+    private void listen(BluetoothSocket bluetoothSocket) {
+        triggerCountdownLatch();
+
+        try {
+            DataInputStream dataInputStream = new DataInputStream(bluetoothSocket.getInputStream());
+            while (true) {
+                int eventGroup = dataInputStream.readUnsignedByte();
+                int eventCode = dataInputStream.readUnsignedByte();
+                int additionalLength = dataInputStream.readUnsignedShort();
+
+                byte[] data = new byte[additionalLength];
+                if (additionalLength > 0) {
+                    int count = 0;
+                    do {
+                        count += dataInputStream.read(data, count, additionalLength - count);
+                    } while (count < additionalLength);
+                }
+
+                if (mRequestHandler != null) {
+                    // In order not to block listening thread, use different thread to dispatch
+                    // message.
+                    mReceiveMessageExecutor.execute(
+                            () -> {
+                                mRequestHandler.handleRequest(eventGroup, eventCode, data);
+                                triggerCountdownLatch();
+                            });
+                }
+            }
+        } catch (IOException e) {
+            log(
+                    String.format(
+                            "IOException when listening to %s",
+                            bluetoothSocket.getRemoteDevice().getAddress()));
+            runInControllerExecutor(() -> handleListenException(bluetoothSocket));
+        }
+    }
+
+    private void handleListenException(BluetoothSocket bluetoothSocket) {
+        if (mIsStopRequested) {
+            stopServer();
+        } else {
+            closeSocket(bluetoothSocket);
+            restartServer();
+        }
+    }
+
+    public void sendFakeEventStreamMessage(EventStreamProtocol.EventGroup eventGroup) {
+        switch (eventGroup) {
+            case BLUETOOTH:
+                send(EventStreamProtocol.EventGroup.BLUETOOTH_VALUE,
+                        EventStreamProtocol.BluetoothEventCode.BLUETOOTH_ENABLE_SILENCE_MODE_VALUE,
+                        new byte[0]);
+                break;
+            case LOGGING:
+                send(EventStreamProtocol.EventGroup.LOGGING_VALUE,
+                        EventStreamProtocol.LoggingEventCode.LOG_FULL_VALUE,
+                        new byte[0]);
+                break;
+            case DEVICE:
+                send(EventStreamProtocol.EventGroup.DEVICE_VALUE,
+                        EventStreamProtocol.DeviceEventCode.DEVICE_BATTERY_INFO_VALUE,
+                        new byte[]{0x11, 0x12, 0x13});
+                break;
+            default: // fall out
+        }
+    }
+
+    public void sendFakeEventStreamLoggingMessage(@Nullable String logContent) {
+        send(EventStreamProtocol.EventGroup.LOGGING_VALUE,
+                EventStreamProtocol.LoggingEventCode.LOG_SAVE_TO_BUFFER_VALUE,
+                logContent != null ? logContent.getBytes(UTF_8) : new byte[0]);
+    }
+
+    public void send(int eventGroup, int eventCode, byte[] data) {
+        runInControllerExecutor(
+                () -> {
+                    if (!CONNECTED.equals(mState)) {
+                        log("Server is not in CONNECTED state, skip send request");
+                        return;
+                    }
+                    BluetoothSocket bluetoothSocket = this.mSocket;
+                    mSendMessageExecutor.execute(() -> {
+                        String address = bluetoothSocket.getRemoteDevice().getAddress();
+                        try {
+                            DataOutputStream dataOutputStream =
+                                    new DataOutputStream(bluetoothSocket.getOutputStream());
+                            dataOutputStream.writeByte(eventGroup);
+                            dataOutputStream.writeByte(eventCode);
+                            dataOutputStream.writeShort(data.length);
+                            if (data.length > 0) {
+                                dataOutputStream.write(data);
+                            }
+                            dataOutputStream.flush();
+                            log(
+                                    String.format(
+                                            "Send message to %s: %s, %s, %s.",
+                                            address, eventGroup, eventCode, data.length));
+                        } catch (IOException e) {
+                            log(
+                                    String.format(
+                                            "Failed to send message to %s: %s, %s, %s.",
+                                            address, eventGroup, eventCode, data.length),
+                                    e);
+                        }
+                    });
+                });
+    }
+
+    /** Stops the rfcomm server. */
+    public void stop() {
+        runInControllerExecutor(() -> {
+            log("Stop RfcommServer");
+
+            if (STOPPED.equals(mState)) {
+                log("Server is stopped, skip stop request.");
+                return;
+            }
+
+            if (mIsStopRequested) {
+                log("Stop is already requested, skip stop request.");
+                return;
+            }
+            mIsStopRequested = true;
+
+            if (ACCEPTING.equals(mState)) {
+                closeServerSocket(mServerSocket);
+            }
+
+            if (CONNECTED.equals(mState)) {
+                closeSocket(mSocket);
+            }
+        });
+    }
+
+    private void stopServer() {
+        updateState(STOPPED);
+        triggerCountdownLatch();
+    }
+
+    private void updateState(State newState) {
+        log(String.format("Change state from %s to %s", mState, newState));
+        if (mStateMonitor != null) {
+            mStateMonitor.onStateChanged(newState);
+        }
+        mState = newState;
+    }
+
+    private void closeServerSocket(BluetoothServerSocket serverSocket) {
+        try {
+            if (serverSocket != null) {
+                log(String.format("Close server socket: %s", serverSocket));
+                serverSocket.close();
+            }
+        } catch (IOException | NullPointerException e) {
+            // NullPointerException is used to skip robolectric test failure.
+            // In unit test, different virtual devices are set up in different threads, calling
+            // ServerSocket.close() in wrong thread will result in NullPointerException since there
+            // is no corresponding service record.
+            // TODO(hylo): Remove NullPointerException when the solution is submitted to test cases.
+            log("Failed to stop server", e);
+        }
+    }
+
+    private void closeSocket(BluetoothSocket socket) {
+        try {
+            if (socket != null && socket.isConnected()) {
+                log(String.format("Close socket: %s", socket.getRemoteDevice().getAddress()));
+                socket.close();
+            }
+        } catch (IOException e) {
+            log(String.format("IOException when close socket %s",
+                    socket.getRemoteDevice().getAddress()));
+        }
+    }
+
+    private void runInControllerExecutor(Runnable runnable) {
+        mControllerExecutor.execute(runnable);
+    }
+
+    private void log(String message) {
+        mLogger.log("Server=%s, %s", FAST_PAIR_RFCOMM_SERVICE_NAME, message);
+    }
+
+    private void log(String message, Throwable e) {
+        mLogger.log(e, "Server=%s, %s", FAST_PAIR_RFCOMM_SERVICE_NAME, message);
+    }
+
+    private void triggerCountdownLatch() {
+        if (mCountDownLatch != null) {
+            mCountDownLatch.countDown();
+        }
+    }
+
+    /** Interface to handle incoming request from clients. */
+    public interface RequestHandler {
+        void handleRequest(int eventGroup, int eventCode, byte[] data);
+    }
+
+    public void setRequestHandler(@Nullable RequestHandler requestHandler) {
+        this.mRequestHandler = requestHandler;
+    }
+
+    /** A state monitor to send signal when state is changed. */
+    public interface StateMonitor {
+        void onStateChanged(State state);
+    }
+
+    public void setStateMonitor(@Nullable StateMonitor stateMonitor) {
+        this.mStateMonitor = stateMonitor;
+    }
+
+    @VisibleForTesting
+    void setCountDownLatch(@Nullable CountDownLatch countDownLatch) {
+        this.mCountDownLatch = countDownLatch;
+    }
+
+    @VisibleForTesting
+    void setIsStopRequested(boolean isStopRequested) {
+        this.mIsStopRequested = isStopRequested;
+    }
+
+    @VisibleForTesting
+    void simulateAcceptIOException() {
+        runInControllerExecutor(() -> {
+            if (ACCEPTING.equals(mState)) {
+                closeServerSocket(mServerSocket);
+            }
+        });
+    }
+
+    @VisibleForTesting
+    void simulateListenIOException() {
+        runInControllerExecutor(() -> {
+            if (CONNECTED.equals(mState)) {
+                closeSocket(mSocket);
+            }
+        });
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/crypto/Crypto.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/crypto/Crypto.java
new file mode 100644
index 0000000..0aa4f6e
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/crypto/Crypto.java
@@ -0,0 +1,70 @@
+/*
+ * 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.fastpair.provider.crypto;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import android.annotation.SuppressLint;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.protobuf.ByteString;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Cryptography utilities for ephemeral IDs. */
+public final class Crypto {
+    private static final int AES_BLOCK_SIZE = 16;
+    private static final ImmutableSet<Integer> VALID_AES_KEY_SIZES = ImmutableSet.of(16, 24, 32);
+    private static final String AES_ECB_NOPADDING_ENCRYPTION_ALGO = "AES/ECB/NoPadding";
+    private static final String AES_ENCRYPTION_ALGO = "AES";
+
+    /** Encrypts the provided data with the provided key using the AES/ECB/NoPadding algorithm. */
+    public static ByteString aesEcbNoPaddingEncrypt(ByteString key, ByteString data) {
+        return aesEcbOperation(key, data, Cipher.ENCRYPT_MODE);
+    }
+
+    /** Decrypts the provided data with the provided key using the AES/ECB/NoPadding algorithm. */
+    public static ByteString aesEcbNoPaddingDecrypt(ByteString key, ByteString data) {
+        return aesEcbOperation(key, data, Cipher.DECRYPT_MODE);
+    }
+
+    @SuppressLint("GetInstance")
+    private static ByteString aesEcbOperation(ByteString key, ByteString data, int operation) {
+        checkArgument(VALID_AES_KEY_SIZES.contains(key.size()));
+        checkArgument(data.size() % AES_BLOCK_SIZE == 0);
+        try {
+            Cipher aesCipher = Cipher.getInstance(AES_ECB_NOPADDING_ENCRYPTION_ALGO);
+            SecretKeySpec secretKeySpec = new SecretKeySpec(key.toByteArray(), AES_ENCRYPTION_ALGO);
+            aesCipher.init(operation, secretKeySpec);
+            ByteBuffer output = ByteBuffer.allocate(data.size());
+            checkState(aesCipher.doFinal(data.asReadOnlyByteBuffer(), output) == data.size());
+            output.rewind();
+            return ByteString.copyFrom(output);
+        } catch (GeneralSecurityException e) {
+            // Should never happen.
+            throw new AssertionError(e);
+        }
+    }
+
+    private Crypto() {
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/crypto/E2eeCalculator.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/crypto/E2eeCalculator.java
new file mode 100644
index 0000000..794c19d
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/crypto/E2eeCalculator.java
@@ -0,0 +1,188 @@
+/*
+ * 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.fastpair.provider.crypto;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Verify;
+import com.google.common.primitives.Bytes;
+import com.google.common.primitives.Ints;
+import com.google.protobuf.ByteString;
+
+import java.math.BigInteger;
+import java.security.spec.ECFieldFp;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.EllipticCurve;
+import java.util.Collections;
+
+/** Provides methods for calculating E2EE EIDs and E2E encryption/decryption based on E2EE EIDs. */
+public final class E2eeCalculator {
+
+    private static final byte[] TEMP_KEY_PADDING_1 =
+            Bytes.toArray(Collections.nCopies(11, (byte) 0xFF));
+    private static final byte[] TEMP_KEY_PADDING_2 = new byte[11];
+    private static final ECParameterSpec CURVE_SPEC = getCurveSpec();
+    private static final BigInteger P = ((ECFieldFp) CURVE_SPEC.getCurve().getField()).getP();
+    private static final BigInteger TWO = new BigInteger("2");
+    private static final BigInteger THREE = new BigInteger("3");
+    private static final int E2EE_EID_IDENTITY_KEY_SIZE = 32;
+    private static final int E2EE_EID_SIZE = 20;
+
+    /**
+     * Computes the E2EE EID value for the given device clock based time. Note that Eddystone
+     * beacons start advertising the new EID at a random time within the window, therefore the
+     * currently advertised EID for beacon time <em>t</em> may be either
+     * {@code computeE2eeEid(eik, k, t)} or {@code computeE2eeEid(eik, k, t - (1 << k))}.
+     *
+     * <p>The E2EE EID computation is based on https://goto.google.com/e2ee-eid-computation.
+     *
+     * @param identityKey        the beacon's 32-byte Eddystone E2EE identity key
+     * @param exponent           rotation period exponent as configured on the beacon, must be in
+     *                           range the [0,15]
+     * @param deviceClockSeconds the value of the beacon's 32-bit seconds time counter (treated as
+     *                           an unsigned value)
+     * @return E2EE EID value.
+     */
+    public static ByteString computeE2eeEid(
+            ByteString identityKey, int exponent, int deviceClockSeconds) {
+        return computePublicKey(computePrivateKey(identityKey, exponent, deviceClockSeconds));
+    }
+
+    private static ByteString computePublicKey(BigInteger privateKey) {
+        return getXCoordinateBytes(toPoint(privateKey));
+    }
+
+    private static BigInteger computePrivateKey(
+            ByteString identityKey, int exponent, int deviceClockSeconds) {
+        Preconditions.checkArgument(
+                Preconditions.checkNotNull(identityKey).size() == E2EE_EID_IDENTITY_KEY_SIZE);
+        Preconditions.checkArgument(exponent >= 0 && exponent < 16);
+
+        byte[] exponentByte = new byte[]{(byte) exponent};
+        byte[] paddedCounter = Ints.toByteArray((deviceClockSeconds >>> exponent) << exponent);
+        byte[] data =
+                Bytes.concat(
+                        TEMP_KEY_PADDING_1,
+                        exponentByte,
+                        paddedCounter,
+                        TEMP_KEY_PADDING_2,
+                        exponentByte,
+                        paddedCounter);
+
+        byte[] rTag =
+                Crypto.aesEcbNoPaddingEncrypt(identityKey, ByteString.copyFrom(data)).toByteArray();
+        return new BigInteger(1, rTag).mod(CURVE_SPEC.getOrder());
+    }
+
+    private static ECPoint toPoint(BigInteger privateKey) {
+        return multiplyPoint(CURVE_SPEC.getGenerator(), privateKey);
+    }
+
+    private static ByteString getXCoordinateBytes(ECPoint point) {
+        byte[] unalignedBytes = point.getAffineX().toByteArray();
+
+        // The unalignedBytes may have length < 32 if the leading E2EE EID bytes are zero, or
+        // it may be E2EE_EID_SIZE + 1 if the leading bit is 1, in which case the first byte is
+        // always zero.
+        Verify.verify(
+                unalignedBytes.length <= E2EE_EID_SIZE
+                        || (unalignedBytes.length == E2EE_EID_SIZE + 1 && unalignedBytes[0] == 0));
+
+        byte[] bytes;
+        if (unalignedBytes.length < E2EE_EID_SIZE) {
+            bytes = new byte[E2EE_EID_SIZE];
+            System.arraycopy(
+                    unalignedBytes, 0, bytes, bytes.length - unalignedBytes.length,
+                    unalignedBytes.length);
+        } else if (unalignedBytes.length == E2EE_EID_SIZE + 1) {
+            bytes = new byte[E2EE_EID_SIZE];
+            System.arraycopy(unalignedBytes, 1, bytes, 0, E2EE_EID_SIZE);
+        } else { // unalignedBytes.length ==  GattE2EE_EID_SIZE
+            bytes = unalignedBytes;
+        }
+        return ByteString.copyFrom(bytes);
+    }
+
+    /** Returns a secp160r1 curve spec. */
+    private static ECParameterSpec getCurveSpec() {
+        final BigInteger p = new BigInteger("ffffffffffffffffffffffffffffffff7fffffff", 16);
+        final BigInteger n = new BigInteger("0100000000000000000001f4c8f927aed3ca752257", 16);
+        final BigInteger a = new BigInteger("ffffffffffffffffffffffffffffffff7ffffffc", 16);
+        final BigInteger b = new BigInteger("1c97befc54bd7a8b65acf89f81d4d4adc565fa45", 16);
+        final BigInteger gx = new BigInteger("4a96b5688ef573284664698968c38bb913cbfc82", 16);
+        final BigInteger gy = new BigInteger("23a628553168947d59dcc912042351377ac5fb32", 16);
+        final int h = 1;
+        ECFieldFp fp = new ECFieldFp(p);
+        EllipticCurve spec = new EllipticCurve(fp, a, b);
+        ECPoint g = new ECPoint(gx, gy);
+        return new ECParameterSpec(spec, g, n, h);
+    }
+
+    /** Returns the scalar multiplication result of k*p in Fp. */
+    private static ECPoint multiplyPoint(ECPoint p, BigInteger k) {
+        ECPoint r = ECPoint.POINT_INFINITY;
+        ECPoint s = p;
+        BigInteger kModP = k.mod(P);
+        int length = kModP.bitLength();
+        for (int i = 0; i <= length - 1; i++) {
+            if (kModP.mod(TWO).byteValue() == 1) {
+                r = addPoint(r, s);
+            }
+            s = doublePoint(s);
+            kModP = kModP.divide(TWO);
+        }
+        return r;
+    }
+
+    /** Returns the point addition r+s in Fp. */
+    private static ECPoint addPoint(ECPoint r, ECPoint s) {
+        if (r.equals(s)) {
+            return doublePoint(r);
+        } else if (r.equals(ECPoint.POINT_INFINITY)) {
+            return s;
+        } else if (s.equals(ECPoint.POINT_INFINITY)) {
+            return r;
+        }
+        BigInteger slope =
+                r.getAffineY()
+                        .subtract(s.getAffineY())
+                        .multiply(r.getAffineX().subtract(s.getAffineX()).modInverse(P))
+                        .mod(P);
+        BigInteger x =
+                slope.modPow(TWO, P).subtract(r.getAffineX()).subtract(s.getAffineX()).mod(P);
+        BigInteger y = s.getAffineY().negate().mod(P);
+        y = y.add(slope.multiply(s.getAffineX().subtract(x))).mod(P);
+        return new ECPoint(x, y);
+    }
+
+    /** Returns the point doubling 2*r in Fp. */
+    private static ECPoint doublePoint(ECPoint r) {
+        if (r.equals(ECPoint.POINT_INFINITY)) {
+            return r;
+        }
+        BigInteger slope = r.getAffineX().pow(2).multiply(THREE);
+        slope = slope.add(CURVE_SPEC.getCurve().getA());
+        slope = slope.multiply(r.getAffineY().multiply(TWO).modInverse(P));
+        BigInteger x = slope.pow(2).subtract(r.getAffineX().multiply(TWO)).mod(P);
+        BigInteger y =
+                r.getAffineY().negate().add(slope.multiply(r.getAffineX().subtract(x))).mod(P);
+        return new ECPoint(x, y);
+    }
+
+    private E2eeCalculator() {
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/Logger.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/Logger.java
new file mode 100644
index 0000000..794f100
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/Logger.java
@@ -0,0 +1,50 @@
+/*
+ * 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.fastpair.provider.utils;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.google.errorprone.annotations.FormatMethod;
+
+/**
+ * The base context for a logging statement.
+ */
+public class Logger {
+    private final String mString;
+
+    public Logger(String tag) {
+        this.mString = tag;
+    }
+
+    @FormatMethod
+    public void log(String message, Object... objects) {
+        log(null, message, objects);
+    }
+
+    /** Logs to the console. */
+    @FormatMethod
+    public void log(@Nullable Throwable exception, String message, Object... objects) {
+        if (exception == null) {
+            Log.i(mString, String.format(message, objects));
+        } else {
+            Log.w(mString, String.format(message, objects));
+            Log.w(mString, String.format("Cause: %s", exception));
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/Reflect.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/Reflect.java
new file mode 100644
index 0000000..5ae5310
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/Reflect.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 android.nearby.fastpair.provider.utils;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Utilities for calling methods using reflection. The main benefit of using this helper is to avoid
+ * complications around exception handling when calling methods reflectively. It's not safe to use
+ * Java 8's multicatch on such exceptions, because the java compiler converts multicatch into
+ * ReflectiveOperationException in some instances, which doesn't work on older sdk versions.
+ * Instead, use these utilities and catch ReflectionException.
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * try {
+ *   Reflect.on(btAdapter)
+ *       .withMethod("setScanMode", int.class)
+ *       .invoke(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
+ * } catch (ReflectionException e) { }
+ * }</pre>
+ */
+public final class Reflect {
+    private final Object mTargetObject;
+
+    private Reflect(Object targetObject) {
+        this.mTargetObject = targetObject;
+    }
+
+    /** Creates an instance of this helper to invoke methods on the given target object. */
+    public static Reflect on(Object targetObject) {
+        return new Reflect(targetObject);
+    }
+
+    /** Finds a method with the given name and parameter types. */
+    public ReflectionMethod withMethod(String methodName, Class<?>... paramTypes)
+            throws ReflectionException {
+        try {
+            return new ReflectionMethod(mTargetObject.getClass().getMethod(methodName, paramTypes));
+        } catch (NoSuchMethodException e) {
+            throw new ReflectionException(e);
+        }
+    }
+
+    /** Represents an invokable method found reflectively. */
+    public final class ReflectionMethod {
+        private final Method mMethod;
+
+        private ReflectionMethod(Method method) {
+            this.mMethod = method;
+        }
+
+        /**
+         * Invokes this instance method with the given parameters. The called method does not return
+         * a value.
+         */
+        public void invoke(Object... parameters) throws ReflectionException {
+            try {
+                mMethod.invoke(mTargetObject, parameters);
+            } catch (IllegalAccessException e) {
+                throw new ReflectionException(e);
+            } catch (InvocationTargetException e) {
+                throw new ReflectionException(e);
+            }
+        }
+
+        /**
+         * Invokes this instance method with the given parameters. The called method returns a non
+         * null
+         * value.
+         */
+        public Object get(Object... parameters) throws ReflectionException {
+            Object value;
+            try {
+                value = mMethod.invoke(mTargetObject, parameters);
+            } catch (IllegalAccessException e) {
+                throw new ReflectionException(e);
+            } catch (InvocationTargetException e) {
+                throw new ReflectionException(e);
+            }
+            if (value == null) {
+                throw new ReflectionException(new NullPointerException());
+            }
+            return value;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/ReflectionException.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/ReflectionException.java
new file mode 100644
index 0000000..959fd11
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/utils/ReflectionException.java
@@ -0,0 +1,27 @@
+/*
+ * 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.fastpair.provider.utils;
+
+/**
+ * An exception thrown during a reflection operation. Like ReflectiveOperationException, except
+ * compatible on older API versions.
+ */
+public final class ReflectionException extends Exception {
+    public ReflectionException(Throwable cause) {
+        super(cause.getMessage(), cause);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/snippet_helper/Android.bp b/nearby/tests/multidevices/clients/test_support/snippet_helper/Android.bp
new file mode 100644
index 0000000..697c88d
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/snippet_helper/Android.bp
@@ -0,0 +1,24 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+    name: "MoblySnippetHelperLib",
+    srcs: ["src/**/*.kt"],
+    sdk_version: "test_current",
+    static_libs: ["mobly-snippet-lib",],
+}
diff --git a/nearby/tests/multidevices/clients/test_support/snippet_helper/AndroidManifest.xml b/nearby/tests/multidevices/clients/test_support/snippet_helper/AndroidManifest.xml
new file mode 100644
index 0000000..4858f46
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/snippet_helper/AndroidManifest.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.mobly.snippet.util">
+
+</manifest>
diff --git a/nearby/tests/multidevices/clients/test_support/snippet_helper/src/com/google/android/mobly/snippet/util/SnippetEventHelper.kt b/nearby/tests/multidevices/clients/test_support/snippet_helper/src/com/google/android/mobly/snippet/util/SnippetEventHelper.kt
new file mode 100644
index 0000000..0dbcb57
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/snippet_helper/src/com/google/android/mobly/snippet/util/SnippetEventHelper.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.google.android.mobly.snippet.util
+
+import android.os.Bundle
+import com.google.android.mobly.snippet.event.EventCache
+import com.google.android.mobly.snippet.event.SnippetEvent
+
+/**
+ * Posts an {@link SnippetEvent} to the event cache with data bundle [fill] by the given function.
+ *
+ * This is a helper function to make your client side codes more concise. Sample usage:
+ * ```
+ *   postSnippetEvent(callbackId, "onReceiverFound") {
+ *     putLong("discoveryTimeMs", discoveryTimeMs)
+ *     putBoolean("isKnown", isKnown)
+ *   }
+ * ```
+ *
+ * @param callbackId the callbackId passed to the {@link
+ * com.google.android.mobly.snippet.rpc.AsyncRpc} method.
+ * @param eventName the name of the event.
+ * @param fill the function to fill the data bundle.
+ */
+fun postSnippetEvent(callbackId: String, eventName: String, fill: Bundle.() -> Unit) {
+  val eventData = Bundle().apply(fill)
+  val snippetEvent = SnippetEvent(callbackId, eventName).apply { data.putAll(eventData) }
+  EventCache.getInstance().postEvent(snippetEvent)
+}
diff --git a/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/Android.bp b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/Android.bp
new file mode 100644
index 0000000..284d5c2
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/Android.bp
@@ -0,0 +1,38 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+// Run the tests: atest --host MoblySnippetHelperRoboTest
+android_robolectric_test {
+    name: "MoblySnippetHelperRoboTest",
+    srcs: ["src/**/*.kt"],
+    instrumentation_for: "NearbyMultiDevicesClientsSnippets",
+    java_resources: ["robolectric.properties"],
+
+    static_libs: [
+        "MoblySnippetHelperLib",
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "junit",
+        "mobly-snippet-lib",
+        "truth-prebuilt",
+    ],
+    test_options: {
+        // timeout in seconds.
+        timeout: 36000,
+    },
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/AndroidManifest.xml b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/AndroidManifest.xml
new file mode 100644
index 0000000..f1fef23
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/AndroidManifest.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.google.android.mobly.snippet.util"/>
diff --git a/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/robolectric.properties b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/robolectric.properties
new file mode 100644
index 0000000..2ea03bb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/robolectric.properties
@@ -0,0 +1,16 @@
+#
+# Copyright (C) 2022 Google Inc.
+#
+# 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.
+#
+sdk=NEWEST_SDK
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/src/com/google/android/mobly/snippet/util/SnippetEventHelperTest.kt b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/src/com/google/android/mobly/snippet/util/SnippetEventHelperTest.kt
new file mode 100644
index 0000000..641ab82
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/snippet_helper/tests/src/com/google/android/mobly/snippet/util/SnippetEventHelperTest.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.google.android.mobly.snippet.util
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.mobly.snippet.event.EventSnippet
+import com.google.android.mobly.snippet.util.Log
+import com.google.common.truth.Truth.assertThat
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Robolectric tests for SnippetEventHelper.kt. */
+@RunWith(RobolectricTestRunner::class)
+class SnippetEventHelperTest {
+
+  @Test
+  fun testPostSnippetEvent_withDataBundle_writesEventCache() {
+    val testCallbackId = "test_1234"
+    val testEventName = "onTestEvent"
+    val testBundleDataStrKey = "testStrKey"
+    val testBundleDataStrValue = "testStrValue"
+    val testBundleDataIntKey = "testIntKey"
+    val testBundleDataIntValue = 777
+    val eventSnippet = EventSnippet()
+    Log.initLogTag(InstrumentationRegistry.getInstrumentation().context)
+
+    postSnippetEvent(testCallbackId, testEventName) {
+      putString(testBundleDataStrKey, testBundleDataStrValue)
+      putInt(testBundleDataIntKey, testBundleDataIntValue)
+    }
+
+    val event = eventSnippet.eventWaitAndGet(testCallbackId, testEventName, null)
+    assertThat(event.getJSONObject("data").toString())
+      .isEqualTo(
+        JSONObject()
+          .put(testBundleDataIntKey, testBundleDataIntValue)
+          .put(testBundleDataStrKey, testBundleDataStrValue)
+          .toString()
+      )
+  }
+}
diff --git a/nearby/tests/multidevices/host/Android.bp b/nearby/tests/multidevices/host/Android.bp
new file mode 100644
index 0000000..933cae9
--- /dev/null
+++ b/nearby/tests/multidevices/host/Android.bp
@@ -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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+// Run the tests: atest -v CtsNearbyMultiDevicesTestSuite
+python_test_host {
+    name: "CtsNearbyMultiDevicesTestSuite",
+    main: "suite_main.py",
+    srcs: ["*.py"],
+    libs: ["NearbyMultiDevicesHostHelper"],
+    test_suites: [
+        "cts",
+        "general-tests",
+    ],
+    test_options: {
+        unit_test: false,
+    },
+    data: [
+        // Package the snippet with the Mobly test.
+        ":NearbyMultiDevicesClientsSnippets",
+        // Package the data provider with the Mobly test.
+        ":NearbyFastPairSeekerDataProvider",
+        // Package the JSON metadata with the Mobly test.
+        "test_data/**/*",
+    ],
+}
+
+python_library_host {
+    name: "NearbyMultiDevicesHostHelper",
+    srcs: ["test_helper/*.py"],
+}
diff --git a/nearby/tests/multidevices/host/AndroidTest.xml b/nearby/tests/multidevices/host/AndroidTest.xml
new file mode 100644
index 0000000..a5f6839
--- /dev/null
+++ b/nearby/tests/multidevices/host/AndroidTest.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<configuration description="Config for CTS Nearby Mainline multi devices end-to-end test suite">
+    <option name="test-suite-tag" value="cts" />
+    <option name="config-descriptor:metadata" key="component" value="wifi" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_secondary_user" />
+
+    <device name="device1">
+        <!-- For coverage to work, the APK should not be uninstalled until after coverage is pulled.
+             So it's a lot easier to install APKs outside the python code.
+        -->
+        <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+            <option name="test-file-name" value="NearbyMultiDevicesClientsSnippets.apk" />
+            <option name="test-file-name" value="NearbyFastPairSeekerDataProvider.apk" />
+            <option name="check-min-sdk" value="true" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+            <option name="run-command" value="input keyevent KEYCODE_WAKEUP" />
+            <option name="run-command" value="wm dismiss-keyguard" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.PythonVirtualenvPreparer">
+          <!-- Any python dependencies can be specified and will be installed with pip -->
+          <!-- TODO(b/225958696): Import python dependencies -->
+          <option name="dep-module" value="mobly" />
+          <option name="dep-module" value="retry" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.DeviceSetup">
+            <option name="force-skip-system-props" value="true" /> <!-- avoid restarting device -->
+            <option name="screen-always-on" value="on" />
+        </target_preparer>
+    </device>
+    <device name="device2">
+        <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+            <option name="test-file-name" value="NearbyMultiDevicesClientsSnippets.apk" />
+            <option name="test-file-name" value="NearbyFastPairSeekerDataProvider.apk" />
+            <option name="check-min-sdk" value="true" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+            <option name="run-command" value="input keyevent KEYCODE_WAKEUP" />
+            <option name="run-command" value="wm dismiss-keyguard" />
+        </target_preparer>
+        <target_preparer class="com.android.tradefed.targetprep.DeviceSetup">
+            <option name="force-skip-system-props" value="true" /> <!-- avoid restarting device -->
+            <option name="screen-always-on" value="on" />
+        </target_preparer>
+    </device>
+
+    <test class="com.android.tradefed.testtype.mobly.MoblyBinaryHostTest">
+      <!-- The mobly-par-file-name should match the module name -->
+      <option name="mobly-par-file-name" value="CtsNearbyMultiDevicesTestSuite" />
+      <!-- Timeout limit in milliseconds for all test cases of the python binary -->
+      <option name="mobly-test-timeout" value="60000" />
+    </test>
+</configuration>
+
diff --git a/nearby/tests/multidevices/host/seeker_discover_provider_test.py b/nearby/tests/multidevices/host/seeker_discover_provider_test.py
new file mode 100644
index 0000000..6356595
--- /dev/null
+++ b/nearby/tests/multidevices/host/seeker_discover_provider_test.py
@@ -0,0 +1,52 @@
+#  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.
+
+"""CTS-V Nearby Mainline Fast Pair end-to-end test case: seeker can discover the provider."""
+
+from test_helper import constants
+from test_helper import fast_pair_base_test
+
+# The model ID to simulate on provider side.
+PROVIDER_SIMULATOR_MODEL_ID = constants.DEFAULT_MODEL_ID
+# The public key to simulate as registered headsets.
+PROVIDER_SIMULATOR_ANTI_SPOOFING_KEY = constants.DEFAULT_ANTI_SPOOFING_KEY
+
+# Time in seconds for events waiting.
+BECOME_DISCOVERABLE_TIMEOUT_SEC = constants.BECOME_DISCOVERABLE_TIMEOUT_SEC
+START_ADVERTISING_TIMEOUT_SEC = constants.START_ADVERTISING_TIMEOUT_SEC
+SCAN_TIMEOUT_SEC = constants.SCAN_TIMEOUT_SEC
+
+
+class SeekerDiscoverProviderTest(fast_pair_base_test.FastPairBaseTest):
+    """Fast Pair seeker discover provider test."""
+
+    def setup_test(self) -> None:
+        super().setup_test()
+        self._provider.start_model_id_advertising(
+            PROVIDER_SIMULATOR_MODEL_ID, PROVIDER_SIMULATOR_ANTI_SPOOFING_KEY)
+        self._provider.wait_for_discoverable_mode(BECOME_DISCOVERABLE_TIMEOUT_SEC)
+        self._provider.wait_for_advertising_start(START_ADVERTISING_TIMEOUT_SEC)
+        self._seeker.start_scan()
+
+    def teardown_test(self) -> None:
+        self._seeker.stop_scan()
+        self._provider.teardown_provider_simulator()
+        super().teardown_test()
+
+    def test_seeker_start_scanning_find_provider(self) -> None:
+        provider_ble_mac_address = self._provider.get_ble_mac_address()
+        self._seeker.wait_and_assert_provider_found(
+            timeout_seconds=SCAN_TIMEOUT_SEC,
+            expected_model_id=PROVIDER_SIMULATOR_MODEL_ID,
+            expected_ble_mac_address=provider_ble_mac_address)
diff --git a/nearby/tests/multidevices/host/seeker_show_halfsheet_test.py b/nearby/tests/multidevices/host/seeker_show_halfsheet_test.py
new file mode 100644
index 0000000..f6561e5
--- /dev/null
+++ b/nearby/tests/multidevices/host/seeker_show_halfsheet_test.py
@@ -0,0 +1,56 @@
+#  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.
+
+"""CTS-V Nearby Mainline Fast Pair end-to-end test case: seeker show half sheet UI."""
+
+from test_helper import constants
+from test_helper import fast_pair_base_test
+
+# The model ID to simulate on provider side.
+PROVIDER_SIMULATOR_MODEL_ID = constants.DEFAULT_MODEL_ID
+# The public key to simulate as registered headsets.
+PROVIDER_SIMULATOR_ANTI_SPOOFING_KEY = constants.DEFAULT_ANTI_SPOOFING_KEY
+# The anti-spoof key device metadata JSON file for data provider at seeker side.
+PROVIDER_SIMULATOR_KDM_JSON_FILE = constants.DEFAULT_KDM_JSON_FILE
+
+# Time in seconds for events waiting.
+SETUP_TIMEOUT_SEC = constants.SETUP_TIMEOUT_SEC
+BECOME_DISCOVERABLE_TIMEOUT_SEC = constants.BECOME_DISCOVERABLE_TIMEOUT_SEC
+START_ADVERTISING_TIMEOUT_SEC = constants.START_ADVERTISING_TIMEOUT_SEC
+HALF_SHEET_POPUP_TIMEOUT_SEC = constants.HALF_SHEET_POPUP_TIMEOUT_SEC
+
+
+class SeekerShowHalfSheetTest(fast_pair_base_test.FastPairBaseTest):
+    """Fast Pair seeker show half sheet UI test."""
+
+    def setup_test(self) -> None:
+        super().setup_test()
+        self._provider.start_model_id_advertising(PROVIDER_SIMULATOR_MODEL_ID,
+                                                  PROVIDER_SIMULATOR_ANTI_SPOOFING_KEY)
+        self._provider.wait_for_discoverable_mode(BECOME_DISCOVERABLE_TIMEOUT_SEC)
+        self._provider.wait_for_advertising_start(START_ADVERTISING_TIMEOUT_SEC)
+        self._seeker.put_anti_spoof_key_device_metadata(PROVIDER_SIMULATOR_MODEL_ID,
+                                                        PROVIDER_SIMULATOR_KDM_JSON_FILE)
+        self._seeker.set_fast_pair_scan_enabled(True)
+
+    def teardown_test(self) -> None:
+        self._seeker.set_fast_pair_scan_enabled(False)
+        self._provider.teardown_provider_simulator()
+        self._seeker.dismiss_halfsheet()
+        super().teardown_test()
+
+    def test_seeker_show_half_sheet(self) -> None:
+        self._seeker.wait_and_assert_halfsheet_showed(
+            timeout_seconds=HALF_SHEET_POPUP_TIMEOUT_SEC,
+            expected_model_id=PROVIDER_SIMULATOR_MODEL_ID)
diff --git a/nearby/tests/multidevices/host/suite_main.py b/nearby/tests/multidevices/host/suite_main.py
new file mode 100644
index 0000000..406a4f0
--- /dev/null
+++ b/nearby/tests/multidevices/host/suite_main.py
@@ -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.
+
+"""The entry point for Nearby Mainline multi devices end-to-end test suite."""
+
+import logging
+import sys
+
+from mobly import suite_runner
+
+import seeker_discover_provider_test
+import seeker_show_halfsheet_test
+
+_BOOTSTRAP_LOGGING_FILENAME = '/tmp/nearby_multi_devices_test_suite_log.txt'
+_TEST_CLASSES_LIST = [
+    seeker_discover_provider_test.SeekerDiscoverProviderTest,
+    seeker_show_halfsheet_test.SeekerShowHalfSheetTest,
+]
+
+
+def _valid_argument(arg: str) -> bool:
+    return arg.startswith(('--config', '-c', '--tests', '--test_case'))
+
+
+if __name__ == '__main__':
+    logging.basicConfig(filename=_BOOTSTRAP_LOGGING_FILENAME, level=logging.INFO)
+    suite_runner.run_suite(argv=[arg for arg in sys.argv if _valid_argument(arg)],
+                           test_classes=_TEST_CLASSES_LIST)
diff --git a/nearby/tests/multidevices/host/test_data/fastpair/pixelbuds-a_account_devicemeta_json.txt b/nearby/tests/multidevices/host/test_data/fastpair/pixelbuds-a_account_devicemeta_json.txt
new file mode 100644
index 0000000..6be58df
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_data/fastpair/pixelbuds-a_account_devicemeta_json.txt
@@ -0,0 +1,66 @@
+[
+  {
+    "account_key": "BPy5AaSyMfrFvMNgr6f7GA==",
+    "sha256_account_key_public_address": "jNGRz+Ni6ZuLd8hVF3lmGoJnF5byXBUyVi9CmnrF1so=",
+    "fast_pair_device_metadata": {
+      "image_url": "https:\/\/lh3.googleusercontent.com\/2PffmZiopo2AjT8sshX0Se3jV-91cp4yOCIay2bBvZqKoKGVy5B4uyzdHsde6UrUSGaoCqV-h4edd5ZljA4oSGc",
+      "intent_uri": "intent:#Intent;action=com.google.android.gms.nearby.discovery%3AACTION_MAGIC_PAIR;package=com.google.android.gms;component=com.google.android.gms\/.nearby.discovery.service.DiscoveryService;S.com.google.android.gms.nearby.discovery%3AEXTRA_COMPANION_APP=com.google.android.apps.wearables.maestro.companion;end",
+      "ble_tx_power": 0,
+      "trigger_distance": 0,
+      "device_type": 0,
+      "left_bud_url": "",
+      "right_bud_url": "",
+      "case_url": "",
+      "initial_notification_description": "Tap to pair. Earbuds will be tied to %s",
+      "initial_notification_description_no_account": "Tap to pair with this device",
+      "initial_pairing_description": "Pixel Buds A-Series will appear on devices linked with ericth.nearby.dogfood@gmail.com",
+      "connect_success_companion_app_installed": "Your device is ready to be set up",
+      "connect_success_companion_app_not_installed": "Download the device app on Google Play to see all available features",
+      "subsequent_pairing_description": "Connect %s to this phone",
+      "retroactive_pairing_description": "Save device to %s for faster pairing to your other devices",
+      "wait_launch_companion_app_description": "This will take a few moments",
+      "fail_connect_go_to_settings_description": "Try manually pairing to the device by going to Settings",
+      "confirm_pin_title": "",
+      "confirm_pin_description": "",
+      "sync_contacts_title": "",
+      "sync_contacts_description": "",
+      "sync_sms_title": "",
+      "sync_sms_description": "",
+      "assistant_setup_half_sheet": "Get hands-free help on the go from Google Assistant",
+      "assistant_setup_notification": "Tap to set up your Google Assistant",
+      "fast_pair_tv_connect_device_no_account_description": "Select connect to pair your %s with this device"
+    },
+    "fast_pair_discovery_item": {
+      "id": "",
+      "type": 6,
+      "mac_address": "",
+      "action_url": "intent:#Intent;action=com.google.android.gms.nearby.discovery%3AACTION_MAGIC_PAIR;package=com.google.android.gms;component=com.google.android.gms\/.nearby.discovery.service.DiscoveryService;S.com.google.android.gms.nearby.discovery%3AEXTRA_COMPANION_APP=com.google.android.apps.wearables.maestro.companion;end",
+      "device_name": "",
+      "title": "Pixel Buds A-Series",
+      "description": "Tap to pair with this device",
+      "display_url": "",
+      "last_observation_timestamp_millis": 0,
+      "first_observation_timestamp_millis": 0,
+      "state": 1,
+      "action_url_type": 2,
+      "rssi": 0,
+      "pending_app_install_timestamp_millis": 0,
+      "tx_power": 0,
+      "app_name": "",
+      "group_id": "",
+      "attachment_type": 0,
+      "package_name": "",
+      "feature_graphic_url": "",
+      "trigger_id": "",
+      "icon_png": "",
+      "icon_fife_url": "https:\/\/lh3.googleusercontent.com\/2PffmZiopo2AjT8sshX0Se3jV-91cp4yOCIay2bBvZqKoKGVy5B4uyzdHsde6UrUSGaoCqV-h4edd5ZljA4oSGc",
+      "debug_message": "",
+      "debug_category": 0,
+      "lost_millis": 0,
+      "last_user_experience": 0,
+      "ble_record_bytes": "",
+      "entity_id": "",
+      "authentication_public_key_secp256r1": "z+grhW8lWVA34JUQhXOxMrk1WqVy+VpEDd2K+01ZJvS6KdV0OUg7FRMzq+ITuOqKO\/2TIRKEAEfMKdyk2Ob1Vw=="
+    }
+  }
+]
\ No newline at end of file
diff --git a/nearby/tests/multidevices/host/test_data/fastpair/pixelbuds-a_antispoofkey_devicemeta_json.txt b/nearby/tests/multidevices/host/test_data/fastpair/pixelbuds-a_antispoofkey_devicemeta_json.txt
new file mode 100644
index 0000000..c03d000
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_data/fastpair/pixelbuds-a_antispoofkey_devicemeta_json.txt
@@ -0,0 +1,38 @@
+{
+  "anti_spoofing_public_key_str": "z+grhW8lWVA34JUQhXOxMrk1WqVy+VpEDd2K+01ZJvS6KdV0OUg7FRMzq+ITuOqKO\/2TIRKEAEfMKdyk2Ob1Vw==",
+  "fast_pair_device_metadata": {
+    "image_url": "https:\/\/lh3.googleusercontent.com\/2PffmZiopo2AjT8sshX0Se3jV-91cp4yOCIay2bBvZqKoKGVy5B4uyzdHsde6UrUSGaoCqV-h4edd5ZljA4oSGc",
+    "intent_uri": "intent:#Intent;action=com.google.android.gms.nearby.discovery%3AACTION_MAGIC_PAIR;package=com.google.android.gms;component=com.google.android.gms\/.nearby.discovery.service.DiscoveryService;S.com.google.android.gms.nearby.discovery%3AEXTRA_COMPANION_APP=com.google.android.apps.wearables.maestro.companion;end",
+    "ble_tx_power": -11,
+    "trigger_distance": 0.6000000238418579,
+    "device_type": 7,
+    "name": "Pixel Buds A-Series",
+    "left_bud_url": "https:\/\/lh3.googleusercontent.com\/O8SVJ5E7CXUkpkym7ibZbp6wypuO7HaTFcslT_FjmEzJX4KHoIY_kzLTdK2kwJXiDBgg8cC__sG-JJ5aVnQtFjQ",
+    "right_bud_url": "https:\/\/lh3.googleusercontent.com\/X_FsRmEKH_fgKzvopyrlyWJAdczRel42Tih7p9-e-U48gBTaggGVQx70K27TzlqIaqYVuaNpTnGoUsKIgiy4WA",
+    "case_url": "https:\/\/lh3.googleusercontent.com\/mNZ7CGplQSpZhoY79jXDQU4B65eY2f0SndnYZLk1PSm8zKTYeRU7REmrLL_pptD6HpVI2F_oQ6xhhtZKOvB8EQ",
+    "locale": "en-US",
+    "initial_notification_description": "Tap to pair. Earbuds will be tied to %s",
+    "initial_notification_description_no_account": "Tap to pair with this device",
+    "open_companion_app_description": "Tap to finish setup",
+    "update_companion_app_description": "Tap to update device settings and finish setup",
+    "download_companion_app_description": "Tap to download device app on Google Play and see all features",
+    "unable_to_connect_title": "Unable to connect",
+    "unable_to_connect_description": "Try manually pairing to the device",
+    "initial_pairing_description": "%s will appear on devices linked with %s",
+    "connect_success_companion_app_installed": "Your device is ready to be set up",
+    "connect_success_companion_app_not_installed": "Download the device app on Google Play to see all available features",
+    "subsequent_pairing_description": "Connect %s to this phone",
+    "retroactive_pairing_description": "Save device to %s for faster pairing to your other devices",
+    "wait_launch_companion_app_description": "This will take a few moments",
+    "fail_connect_go_to_settings_description": "Try manually pairing to the device by going to Settings",
+    "confirm_pin_title": "",
+    "confirm_pin_description": "",
+    "sync_contacts_title": "",
+    "sync_contacts_description": "",
+    "sync_sms_title": "",
+    "sync_sms_description": "",
+    "assistant_setup_half_sheet": "Get hands-free help on the go from Google Assistant",
+    "assistant_setup_notification": "Tap to set up your Google Assistant",
+    "fast_pair_tv_connect_device_no_account_description": "Select connect to pair your %s with this device"
+  }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/host/test_data/fastpair/simulator_account_devicemeta_json.txt b/nearby/tests/multidevices/host/test_data/fastpair/simulator_account_devicemeta_json.txt
new file mode 100644
index 0000000..392c443
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_data/fastpair/simulator_account_devicemeta_json.txt
@@ -0,0 +1,66 @@
+[
+  {
+    "account_key": "BPy5AaSyMfrFvMNgr6f7GA==",
+    "sha256_account_key_public_address": "jNGRz+Ni6ZuLd8hVF3lmGoJnF5byXBUyVi9CmnrF1so=",
+    "fast_pair_device_metadata": {
+      "assistant_setup_half_sheet": "Get hands-free help on the go from Google Assistant",
+      "assistant_setup_notification": "Tap to set up your Google Assistant",
+      "ble_tx_power": 0,
+      "case_url": "",
+      "confirm_pin_description": "",
+      "confirm_pin_title": "",
+      "connect_success_companion_app_installed": "Your device is ready to be set up",
+      "connect_success_companion_app_not_installed": "Download the device app on Google Play to see all available features",
+      "device_type": 0,
+      "fail_connect_go_to_settings_description": "Try manually pairing to the device by going to Settings",
+      "fast_pair_tv_connect_device_no_account_description": "Select connect to pair your %s with this device",
+      "image_url": "https://lh3.googleusercontent.com/2PffmZiopo2AjT8sshX0Se3jV-91cp4yOCIay2bBvZqKoKGVy5B4uyzdHsde6UrUSGaoCqV-h4edd5ZljA4oSGc",
+      "initial_notification_description": "Tap to pair. Earbuds will be tied to %s",
+      "initial_notification_description_no_account": "Tap to pair with this device",
+      "initial_pairing_description": "Pixel Buds A-Series will appear on devices linked with ericth.nearby.dogfood@gmail.com",
+      "intent_uri": "intent:#Intent;action=com.google.android.gms.nearby.discovery%3AACTION_MAGIC_PAIR;package=com.google.android.gms;component=com.google.android.gms/.nearby.discovery.service.DiscoveryService;S.com.google.android.gms.nearby.discovery%3AEXTRA_COMPANION_APP=com.google.android.apps.wearables.maestro.companion;end",
+      "left_bud_url": "",
+      "retroactive_pairing_description": "Save device to %s for faster pairing to your other devices",
+      "right_bud_url": "",
+      "subsequent_pairing_description": "Connect %s to this phone",
+      "sync_contacts_description": "",
+      "sync_contacts_title": "",
+      "sync_sms_description": "",
+      "sync_sms_title": "",
+      "trigger_distance": 0,
+      "wait_launch_companion_app_description": "This will take a few moments"
+    },
+    "fast_pair_discovery_item": {
+      "action_url": "intent:#Intent;action=com.google.android.gms.nearby.discovery%3AACTION_MAGIC_PAIR;package=com.google.android.gms;component=com.google.android.gms/.nearby.discovery.service.DiscoveryService;S.com.google.android.gms.nearby.discovery%3AEXTRA_COMPANION_APP=com.google.android.apps.wearables.maestro.companion;end",
+      "action_url_type": 2,
+      "app_name": "",
+      "attachment_type": 0,
+      "authentication_public_key_secp256r1": "z+grhW8lWVA34JUQhXOxMrk1WqVy+VpEDd2K+01ZJvS6KdV0OUg7FRMzq+ITuOqKO/2TIRKEAEfMKdyk2Ob1Vw==",
+      "ble_record_bytes": "",
+      "debug_category": 0,
+      "debug_message": "",
+      "description": "Tap to pair with this device",
+      "device_name": "",
+      "display_url": "",
+      "entity_id": "",
+      "feature_graphic_url": "",
+      "first_observation_timestamp_millis": 0,
+      "group_id": "",
+      "icon_fife_url": "https://lh3.googleusercontent.com/2PffmZiopo2AjT8sshX0Se3jV-91cp4yOCIay2bBvZqKoKGVy5B4uyzdHsde6UrUSGaoCqV-h4edd5ZljA4oSGc",
+      "icon_png": "",
+      "id": "",
+      "last_observation_timestamp_millis": 0,
+      "last_user_experience": 0,
+      "lost_millis": 0,
+      "mac_address": "",
+      "package_name": "",
+      "pending_app_install_timestamp_millis": 0,
+      "rssi": 0,
+      "state": 1,
+      "title": "Pixel Buds A-Series",
+      "trigger_id": "",
+      "tx_power": 0,
+      "type": 6
+    }
+  }
+]
\ No newline at end of file
diff --git a/nearby/tests/multidevices/host/test_data/fastpair/simulator_antispoofkey_devicemeta_json.txt b/nearby/tests/multidevices/host/test_data/fastpair/simulator_antispoofkey_devicemeta_json.txt
new file mode 100644
index 0000000..7cb29f4
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_data/fastpair/simulator_antispoofkey_devicemeta_json.txt
@@ -0,0 +1,38 @@
+{
+  "anti_spoofing_public_key_str": "sjp\/AOS7+VnTCaueeWorjdeJ8Nc32EOmpe\/QRhzY9+cMNELU1QA3jzgvUXdWW73nl6+EN01eXtLBu2Fw9CGmfA==",
+  "fast_pair_device_metadata": {
+    "assistant_setup_half_sheet": "Get hands-free help on the go from Google Assistant",
+    "assistant_setup_notification": "Tap to set up your Google Assistant",
+    "ble_tx_power": -10,
+    "case_url": "https://lh3.googleusercontent.com/mNZ7CGplQSpZhoY79jXDQU4B65eY2f0SndnYZLk1PSm8zKTYeRU7REmrLL_pptD6HpVI2F_oQ6xhhtZKOvB8EQ",
+    "confirm_pin_description": "",
+    "confirm_pin_title": "",
+    "connect_success_companion_app_installed": "Your device is ready to be set up",
+    "connect_success_companion_app_not_installed": "Download the device app on Google Play to see all available features",
+    "device_type": 7,
+    "download_companion_app_description": "Tap to download device app on Google Play and see all features",
+    "fail_connect_go_to_settings_description": "Try manually pairing to the device by going to Settings",
+    "fast_pair_tv_connect_device_no_account_description": "Select connect to pair your %s with this device",
+    "image_url": "https://lh3.googleusercontent.com/THpAzISZGa5F86cMsBcTPhRWefBPc5dorBxWdOPCGvbFg6ZMHUjFuE-4kbLuoLoIMHf3Fd8jUvvcxnjp_Q",
+    "initial_notification_description": "Tap to pair. Earbuds will be tied to %s",
+    "initial_notification_description_no_account": "Tap to pair with this device",
+    "initial_pairing_description": "%s will appear on devices linked with %s",
+    "intent_uri": "intent:#Intent;action=com.google.android.gms.nearby.discovery%3AACTION_MAGIC_PAIR;package=com.google.android.gms;component=com.google.android.gms/.nearby.discovery.service.DiscoveryService;S.com.google.android.gms.nearby.discovery%3AEXTRA_COMPANION_APP=com.google.android.testapp;end",
+    "left_bud_url": "https://lh3.googleusercontent.com/O8SVJ5E7CXUkpkym7ibZbp6wypuO7HaTFcslT_FjmEzJX4KHoIY_kzLTdK2kwJXiDBgg8cC__sG-JJ5aVnQtFjQ",
+    "locale": "en-US",
+    "name": "Fast Pair Provider Simulator",
+    "open_companion_app_description": "Tap to finish setup",
+    "retroactive_pairing_description": "Save device to %s for faster pairing to your other devices",
+    "right_bud_url": "https://lh3.googleusercontent.com/X_FsRmEKH_fgKzvopyrlyWJAdczRel42Tih7p9-e-U48gBTaggGVQx70K27TzlqIaqYVuaNpTnGoUsKIgiy4WA",
+    "subsequent_pairing_description": "Connect %s to this phone",
+    "sync_contacts_description": "",
+    "sync_contacts_title": "",
+    "sync_sms_description": "",
+    "sync_sms_title": "",
+    "trigger_distance": 0.6000000238418579,
+    "unable_to_connect_description": "Try manually pairing to the device",
+    "unable_to_connect_title": "Unable to connect",
+    "update_companion_app_description": "Tap to update device settings and finish setup",
+    "wait_launch_companion_app_description": "This will take a few moments"
+  }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/host/test_helper/__init__.py b/nearby/tests/multidevices/host/test_helper/__init__.py
new file mode 100644
index 0000000..b0cae91
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_helper/__init__.py
@@ -0,0 +1,13 @@
+#  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.
diff --git a/nearby/tests/multidevices/host/test_helper/constants.py b/nearby/tests/multidevices/host/test_helper/constants.py
new file mode 100644
index 0000000..646b428
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_helper/constants.py
@@ -0,0 +1,36 @@
+#  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.
+
+# Default model ID to simulate on provider side.
+DEFAULT_MODEL_ID = '00000c'
+
+# Default public key to simulate as registered headsets.
+DEFAULT_ANTI_SPOOFING_KEY = 'Cbj9eCJrTdDgSYxLkqtfADQi86vIaMvxJsQ298sZYWE='
+
+# Default anti-spoof Key Device Metadata JSON file for data provider at seeker side.
+DEFAULT_KDM_JSON_FILE = 'simulator_antispoofkey_devicemeta_json.txt'
+
+# Time in seconds for events waiting.
+SETUP_TIMEOUT_SEC = 5
+BECOME_DISCOVERABLE_TIMEOUT_SEC = 10
+START_ADVERTISING_TIMEOUT_SEC = 5
+SCAN_TIMEOUT_SEC = 30
+HALF_SHEET_POPUP_TIMEOUT_SEC = 30
+
+# The phone to simulate Fast Pair provider (like headphone) needs changes in Android system:
+# 1. System permission check removal
+# 2. Adjusts Bluetooth profile configurations
+# The build fingerprint of the custom ROM for Fast Pair provider simulator.
+FAST_PAIR_PROVIDER_SIMULATOR_BUILD_FINGERPRINT = (
+    'google/bramble/bramble:Tiramisu/MASTER/eng.hylo.20211019.091550:userdebug/dev-keys')
diff --git a/nearby/tests/multidevices/host/test_helper/event_helper.py b/nearby/tests/multidevices/host/test_helper/event_helper.py
new file mode 100644
index 0000000..8abf05c
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_helper/event_helper.py
@@ -0,0 +1,69 @@
+#  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.
+
+"""This is a shared library to help handling Mobly event waiting logic."""
+
+import time
+from typing import Callable
+
+from mobly.controllers.android_device_lib import callback_handler
+from mobly.controllers.android_device_lib import snippet_event
+
+# Abbreviations for common use type
+CallbackHandler = callback_handler.CallbackHandler
+SnippetEvent = snippet_event.SnippetEvent
+
+# Type definition for the callback functions to make code formatted nicely
+OnReceivedCallback = Callable[[SnippetEvent, int], bool]
+OnWaitingCallback = Callable[[int], None]
+OnMissedCallback = Callable[[], None]
+
+
+def wait_callback_event(callback_event_handler: CallbackHandler,
+                        event_name: str, timeout_seconds: int,
+                        on_received: OnReceivedCallback,
+                        on_waiting: OnWaitingCallback,
+                        on_missed: OnMissedCallback) -> None:
+    """Waits until the matched event has been received or timeout.
+
+    Here we keep waitAndGet for event callback from EventSnippet.
+    We loop until over timeout_seconds instead of directly
+    waitAndGet(timeout=teardown_timeout_seconds). Because there is
+    MAX_TIMEOUT limitation in callback_handler of Mobly.
+
+    Args:
+      callback_event_handler: Mobly callback events handler.
+      event_name: the specific name of the event to wait.
+      timeout_seconds: the number of seconds to wait before giving up.
+      on_received: calls when event received, return false to keep waiting.
+      on_waiting: calls when waitAndGet timeout.
+      on_missed: calls when giving up.
+    """
+    start_time = time.perf_counter()
+    deadline = start_time + timeout_seconds
+    while time.perf_counter() < deadline:
+        remaining_time_sec = min(callback_handler.DEFAULT_TIMEOUT,
+                                 deadline - time.perf_counter())
+        try:
+            event = callback_event_handler.waitAndGet(
+                event_name, timeout=remaining_time_sec)
+        except callback_handler.TimeoutError:
+            elapsed_time = int(time.perf_counter() - start_time)
+            on_waiting(elapsed_time)
+        else:
+            elapsed_time = int(time.perf_counter() - start_time)
+            if on_received(event, elapsed_time):
+                break
+    else:
+        on_missed()
diff --git a/nearby/tests/multidevices/host/test_helper/fast_pair_base_test.py b/nearby/tests/multidevices/host/test_helper/fast_pair_base_test.py
new file mode 100644
index 0000000..8b84839
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_helper/fast_pair_base_test.py
@@ -0,0 +1,75 @@
+#  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.
+
+"""Base for all Nearby Mainline Fast Pair end-to-end test cases."""
+
+from typing import List, Tuple
+
+from mobly import base_test
+from mobly import signals
+from mobly.controllers import android_device
+
+from test_helper import constants
+from test_helper import fast_pair_provider_simulator
+from test_helper import fast_pair_seeker
+
+# Abbreviations for common use type.
+AndroidDevice = android_device.AndroidDevice
+FastPairProviderSimulator = fast_pair_provider_simulator.FastPairProviderSimulator
+FastPairSeeker = fast_pair_seeker.FastPairSeeker
+REQUIRED_BUILD_FINGERPRINT = constants.FAST_PAIR_PROVIDER_SIMULATOR_BUILD_FINGERPRINT
+
+
+class FastPairBaseTest(base_test.BaseTestClass):
+    """Base class for all Nearby Mainline Fast Pair end-to-end classes to inherit."""
+
+    _duts: List[AndroidDevice]
+    _provider: FastPairProviderSimulator
+    _seeker: FastPairSeeker
+
+    def setup_class(self) -> None:
+        super().setup_class()
+        self._duts = self.register_controller(android_device, min_number=2)
+
+        provider_ad, seeker_ad = self._check_devices_supported()
+        self._provider = FastPairProviderSimulator(provider_ad)
+        self._seeker = FastPairSeeker(seeker_ad)
+        self._provider.load_snippet()
+        self._seeker.load_snippet()
+
+    def setup_test(self) -> None:
+        super().setup_test()
+        self._provider.setup_provider_simulator(constants.SETUP_TIMEOUT_SEC)
+
+    def teardown_test(self) -> None:
+        super().teardown_test()
+        # Create per-test excepts of logcat.
+        for dut in self._duts:
+            dut.services.create_output_excerpts_all(self.current_test_info)
+
+    def _check_devices_supported(self) -> Tuple[AndroidDevice, AndroidDevice]:
+        # Assume the 1st phone is provider, the 2nd one is seeker.
+        provider_ad, seeker_ad = self._duts[:2]
+
+        for ad in self._duts:
+            if ad.build_info['build_fingerprint'] == REQUIRED_BUILD_FINGERPRINT:
+                if ad != provider_ad:
+                    provider_ad, seeker_ad = seeker_ad, provider_ad
+                break
+        else:
+            raise signals.TestAbortClass(
+                f'None of phones has custom ROM ({REQUIRED_BUILD_FINGERPRINT}) for Fast Pair '
+                f'provider simulator. Skip all the test cases!')
+
+        return provider_ad, seeker_ad
diff --git a/nearby/tests/multidevices/host/test_helper/fast_pair_provider_simulator.py b/nearby/tests/multidevices/host/test_helper/fast_pair_provider_simulator.py
new file mode 100644
index 0000000..8a98112
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_helper/fast_pair_provider_simulator.py
@@ -0,0 +1,181 @@
+#  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.
+
+"""Fast Pair provider simulator role."""
+
+from mobly import asserts
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import snippet_event
+import retry
+
+from test_helper import event_helper
+
+# The package name of the provider simulator snippet.
+FP_PROVIDER_SIMULATOR_SNIPPETS_PACKAGE = 'android.nearby.multidevices'
+
+# Events reported from the provider simulator snippet.
+ON_A2DP_SINK_PROFILE_CONNECT_EVENT = 'onA2DPSinkProfileConnected'
+ON_SCAN_MODE_CHANGE_EVENT = 'onScanModeChange'
+ON_ADVERTISING_CHANGE_EVENT = 'onAdvertisingChange'
+
+# Target scan mode.
+DISCOVERABLE_MODE = 'DISCOVERABLE'
+
+# Abbreviations for common use type.
+AndroidDevice = android_device.AndroidDevice
+SnippetEvent = snippet_event.SnippetEvent
+wait_for_event = event_helper.wait_callback_event
+
+
+class FastPairProviderSimulator:
+    """A proxy for provider simulator snippet on the device."""
+
+    def __init__(self, ad: AndroidDevice) -> None:
+        self._ad = ad
+        self._ad.debug_tag = 'FastPairProviderSimulator'
+        self._provider_status_callback = None
+
+    def load_snippet(self) -> None:
+        """Starts the provider simulator snippet and connects.
+
+        Raises:
+          SnippetError: Illegal load operations are attempted.
+        """
+        self._ad.load_snippet(
+            name='fp', package=FP_PROVIDER_SIMULATOR_SNIPPETS_PACKAGE)
+
+    def setup_provider_simulator(self, timeout_seconds: int) -> None:
+        """Sets up the Fast Pair provider simulator.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+        """
+        setup_status_callback = self._ad.fp.setupProviderSimulator()
+
+        def _on_a2dp_sink_profile_connect_event_received(_, elapsed_time: int) -> bool:
+            self._ad.log.info('Provider simulator connected to A2DP sink in %d seconds.',
+                              elapsed_time)
+            return True
+
+        def _on_a2dp_sink_profile_connect_event_waiting(elapsed_time: int) -> None:
+            self._ad.log.info(
+                'Still waiting "%s" event callback from provider side '
+                'after %d seconds...', ON_A2DP_SINK_PROFILE_CONNECT_EVENT, elapsed_time)
+
+        def _on_a2dp_sink_profile_connect_event_missed() -> None:
+            asserts.fail(f'Timed out after {timeout_seconds} seconds waiting for '
+                         f'the specific "{ON_A2DP_SINK_PROFILE_CONNECT_EVENT}" event.')
+
+        wait_for_event(
+            callback_event_handler=setup_status_callback,
+            event_name=ON_A2DP_SINK_PROFILE_CONNECT_EVENT,
+            timeout_seconds=timeout_seconds,
+            on_received=_on_a2dp_sink_profile_connect_event_received,
+            on_waiting=_on_a2dp_sink_profile_connect_event_waiting,
+            on_missed=_on_a2dp_sink_profile_connect_event_missed)
+
+    def start_model_id_advertising(self, model_id: str, anti_spoofing_key: str) -> None:
+        """Starts model id advertising for scanning and initial pairing.
+
+        Args:
+          model_id: A 3-byte hex string for seeker side to recognize the device (ex:
+            0x00000C).
+          anti_spoofing_key: A public key for registered headsets.
+        """
+        self._ad.log.info(
+            'Provider simulator starts advertising as model id "%s" with anti-spoofing key "%s".',
+            model_id, anti_spoofing_key)
+        self._provider_status_callback = (
+            self._ad.fp.startModelIdAdvertising(model_id, anti_spoofing_key))
+
+    def teardown_provider_simulator(self) -> None:
+        """Tears down the Fast Pair provider simulator."""
+        self._ad.fp.teardownProviderSimulator()
+
+    @retry.retry(tries=3)
+    def get_ble_mac_address(self) -> str:
+        """Gets Bluetooth low energy mac address of the provider simulator.
+
+        The BLE mac address will be set by the AdvertisingSet.getOwnAddress()
+        callback. This is the callback flow in the custom Android build. It takes
+        a while after advertising started so we use retry here to wait it.
+
+        Returns:
+          The BLE mac address of the Fast Pair provider simulator.
+        """
+        return self._ad.fp.getBluetoothLeAddress()
+
+    def wait_for_discoverable_mode(self, timeout_seconds: int) -> None:
+        """Waits onScanModeChange event to ensure provider is discoverable.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+        """
+
+        def _on_scan_mode_change_event_received(
+                scan_mode_change_event: SnippetEvent, elapsed_time: int) -> bool:
+            scan_mode = scan_mode_change_event.data['mode']
+            self._ad.log.info(
+                'Provider simulator changed the scan mode to %s in %d seconds.',
+                scan_mode, elapsed_time)
+            return scan_mode == DISCOVERABLE_MODE
+
+        def _on_scan_mode_change_event_waiting(elapsed_time: int) -> None:
+            self._ad.log.info(
+                'Still waiting "%s" event callback from provider side '
+                'after %d seconds...', ON_SCAN_MODE_CHANGE_EVENT, elapsed_time)
+
+        def _on_scan_mode_change_event_missed() -> None:
+            asserts.fail(f'Timed out after {timeout_seconds} seconds waiting for '
+                         f'the specific "{ON_SCAN_MODE_CHANGE_EVENT}" event.')
+
+        wait_for_event(
+            callback_event_handler=self._provider_status_callback,
+            event_name=ON_SCAN_MODE_CHANGE_EVENT,
+            timeout_seconds=timeout_seconds,
+            on_received=_on_scan_mode_change_event_received,
+            on_waiting=_on_scan_mode_change_event_waiting,
+            on_missed=_on_scan_mode_change_event_missed)
+
+    def wait_for_advertising_start(self, timeout_seconds: int) -> None:
+        """Waits onAdvertisingChange event to ensure provider is advertising.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+        """
+
+        def _on_advertising_mode_change_event_received(
+                scan_mode_change_event: SnippetEvent, elapsed_time: int) -> bool:
+            advertising_mode = scan_mode_change_event.data['isAdvertising']
+            self._ad.log.info(
+                'Provider simulator changed the advertising mode to %s in %d seconds.',
+                advertising_mode, elapsed_time)
+            return advertising_mode
+
+        def _on_advertising_mode_change_event_waiting(elapsed_time: int) -> None:
+            self._ad.log.info(
+                'Still waiting "%s" event callback from provider side '
+                'after %d seconds...', ON_ADVERTISING_CHANGE_EVENT, elapsed_time)
+
+        def _on_advertising_mode_change_event_missed() -> None:
+            asserts.fail(f'Timed out after {timeout_seconds} seconds waiting for '
+                         f'the specific "{ON_ADVERTISING_CHANGE_EVENT}" event.')
+
+        wait_for_event(
+            callback_event_handler=self._provider_status_callback,
+            event_name=ON_ADVERTISING_CHANGE_EVENT,
+            timeout_seconds=timeout_seconds,
+            on_received=_on_advertising_mode_change_event_received,
+            on_waiting=_on_advertising_mode_change_event_waiting,
+            on_missed=_on_advertising_mode_change_event_missed)
diff --git a/nearby/tests/multidevices/host/test_helper/fast_pair_seeker.py b/nearby/tests/multidevices/host/test_helper/fast_pair_seeker.py
new file mode 100644
index 0000000..cfdb966
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_helper/fast_pair_seeker.py
@@ -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.
+
+"""Fast Pair seeker role."""
+
+from mobly import asserts
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import snippet_event
+
+from test_helper import event_helper
+from test_helper import utils
+
+# The package name of the Nearby Mainline Fast Pair seeker Mobly snippet.
+FP_SEEKER_SNIPPETS_PACKAGE = 'android.nearby.multidevices'
+
+# Events reported from the seeker snippet.
+ON_PROVIDER_FOUND_EVENT = 'onDiscovered'
+
+# Abbreviations for common use type.
+AndroidDevice = android_device.AndroidDevice
+JsonObject = utils.JsonObject
+SnippetEvent = snippet_event.SnippetEvent
+wait_for_event = event_helper.wait_callback_event
+
+
+class FastPairSeeker:
+    """A proxy for seeker snippet on the device."""
+
+    def __init__(self, ad: AndroidDevice) -> None:
+        self._ad = ad
+        self._ad.debug_tag = 'MainlineFastPairSeeker'
+        self._scan_result_callback = None
+
+    def load_snippet(self) -> None:
+        """Starts the seeker snippet and connects.
+
+        Raises:
+          SnippetError: Illegal load operations are attempted.
+        """
+        self._ad.load_snippet(name='fp', package=FP_SEEKER_SNIPPETS_PACKAGE)
+
+    def start_scan(self) -> None:
+        """Starts scanning to find Fast Pair provider devices."""
+        self._scan_result_callback = self._ad.fp.startScan()
+
+    def stop_scan(self) -> None:
+        """Stops the Fast Pair seeker scanning."""
+        self._ad.fp.stopScan()
+
+    def start_pair(self, model_id: str, address: str) -> None:
+        """Starts the Fast Pair seeker pairing.
+
+        Args:
+          model_id: A 3-byte hex string for seeker side to recognize the provider
+            device (ex: 0x00000C).
+          address: The BLE mac address of the Fast Pair provider.
+        """
+        self._ad.log.info('Before calling startPairing')
+        self._ad.fp.startPairing(model_id, address)
+        self._ad.log.info('After calling startPairing')
+
+    def wait_and_assert_provider_found(self, timeout_seconds: int,
+                                       expected_model_id: str,
+                                       expected_ble_mac_address: str) -> None:
+        """Waits and asserts any onDiscovered event from the seeker.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+          expected_model_id: The expected model ID of the remote Fast Pair provider
+            device.
+          expected_ble_mac_address: The expected BLE MAC address of the remote Fast
+            Pair provider device.
+        """
+
+        def _on_provider_found_event_received(provider_found_event: SnippetEvent,
+                                              elapsed_time: int) -> bool:
+            nearby_device_str = provider_found_event.data['device']
+            self._ad.log.info('Seeker discovered first provider(%s) in %d seconds.',
+                              nearby_device_str, elapsed_time)
+            return expected_ble_mac_address in nearby_device_str
+
+        def _on_provider_found_event_waiting(elapsed_time: int) -> None:
+            self._ad.log.info(
+                'Still waiting "%s" event callback from seeker side '
+                'after %d seconds...', ON_PROVIDER_FOUND_EVENT, elapsed_time)
+
+        def _on_provider_found_event_missed() -> None:
+            asserts.fail(f'Timed out after {timeout_seconds} seconds waiting for '
+                         f'the specific "{ON_PROVIDER_FOUND_EVENT}" event.')
+
+        wait_for_event(
+            callback_event_handler=self._scan_result_callback,
+            event_name=ON_PROVIDER_FOUND_EVENT,
+            timeout_seconds=timeout_seconds,
+            on_received=_on_provider_found_event_received,
+            on_waiting=_on_provider_found_event_waiting,
+            on_missed=_on_provider_found_event_missed)
+
+    def put_anti_spoof_key_device_metadata(self, model_id: str, kdm_json_file_name: str) -> None:
+        """Puts a model id to FastPairAntispoofKeyDeviceMetadata pair into test data cache.
+
+        Args:
+          model_id: A string of model id to be associated with.
+          kdm_json_file_name: The FastPairAntispoofKeyDeviceMetadata JSON object.
+        """
+        self._ad.log.info('Puts FastPairAntispoofKeyDeviceMetadata into test data cache for '
+                          'model id "%s".', model_id)
+        kdm_json_object = utils.load_json_fast_pair_test_data(kdm_json_file_name)
+        self._ad.fp.putAntispoofKeyDeviceMetadata(
+            model_id,
+            utils.serialize_as_simplified_json_str(kdm_json_object))
+
+    def set_fast_pair_scan_enabled(self, enable: bool) -> None:
+        """Writes into Settings whether Fast Pair scan is enabled.
+
+        Args:
+          enable: whether the Fast Pair scan should be enabled.
+        """
+        self._ad.log.info('%s Fast Pair scan in Android settings.',
+                          'Enables' if enable else 'Disables')
+        self._ad.fp.setFastPairScanEnabled(enable)
+
+    def wait_and_assert_halfsheet_showed(self, timeout_seconds: int,
+                                         expected_model_id: str) -> None:
+        """Waits and asserts the onHalfSheetShowed event from the seeker.
+
+        Args:
+          timeout_seconds: The number of seconds to wait before giving up.
+          expected_model_id: The expected model ID of the remote Fast Pair provider
+            device.
+        """
+        self._ad.log.info('Waits and asserts the half sheet showed for model id "%s".',
+                          expected_model_id)
+        self._ad.fp.waitAndAssertHalfSheetShowed(expected_model_id, timeout_seconds)
+
+    def dismiss_halfsheet(self) -> None:
+        """Dismisses the half sheet UI if showed."""
+        self._ad.fp.dismissHalfSheet()
diff --git a/nearby/tests/multidevices/host/test_helper/utils.py b/nearby/tests/multidevices/host/test_helper/utils.py
new file mode 100644
index 0000000..a0acb57
--- /dev/null
+++ b/nearby/tests/multidevices/host/test_helper/utils.py
@@ -0,0 +1,42 @@
+#  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.
+
+import json
+import pathlib
+import sys
+from typing import Any, Dict
+
+# Type definition
+JsonObject = Dict[str, Any]
+
+
+def load_json_fast_pair_test_data(json_file_name: str) -> JsonObject:
+    """Loads a JSON text file from test data directory into a Json object.
+
+    Args:
+      json_file_name: The name of the JSON file.
+    """
+    return json.loads(
+        pathlib.Path(sys.argv[0]).parent.joinpath(
+            'test_data', 'fastpair', json_file_name).read_text()
+    )
+
+
+def serialize_as_simplified_json_str(json_data: JsonObject) -> str:
+    """Serializes a JSON object into a string without empty space.
+
+    Args:
+      json_data: The JSON object to be serialized.
+    """
+    return json.dumps(json_data, separators=(',', ':'))
diff --git a/nearby/tests/multidevices/host/tool/fast_pair_data_provider_shell.sh b/nearby/tests/multidevices/host/tool/fast_pair_data_provider_shell.sh
new file mode 100755
index 0000000..a74c1a9
--- /dev/null
+++ b/nearby/tests/multidevices/host/tool/fast_pair_data_provider_shell.sh
@@ -0,0 +1,107 @@
+#!/bin/bash
+
+#
+# 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.
+
+# A script to interactively manage FastPairTestDataCache of FastPairTestDataProviderService.
+#
+# FastPairTestDataProviderService (../../clients/test_service/fastpair_seeker_data_provider/) is a
+# run-Time configurable FastPairDataProviderService. It has a FastPairTestDataManager to receive
+# Intent broadcast to add/clear the FastPairTestDataCache. This cache provides the data to return to
+# the Nearby Mainline module for onXXX calls (ex: onLoadFastPairAntispoofKeyDeviceMetadata).
+#
+# To use this tool, make sure you:
+# 1. Flash the ROM your built to the device
+# 2. Build and install NearbyFastPairSeekerDataProvider to the device
+# m NearbyFastPairSeekerDataProvider
+# adb install -r -g ${ANDROID_PRODUCT_OUT}/system/app/NearbyFastPairSeekerDataProvider/NearbyFastPairSeekerDataProvider.apk
+# 3. Check FastPairService can connect to the FastPairTestDataProviderService.
+# adb logcat ServiceMonitor:* *:S
+# (ex: ServiceMonitor: [FAST_PAIR_DATA_PROVIDER] connected to {
+# android.nearby.fastpair.seeker.dataprovider/android.nearby.fastpair.seeker.dataprovider.FastPairTestDataProviderService})
+#
+# Sample Usages:
+# 1. Send FastPairAntispoofKeyDeviceMetadata for PixelBuds-A to FastPairTestDataCache
+# ./fast_pair_data_provider_shell.sh -m=718c17  -a=../test_data/fastpair/pixelbuds-a_antispoofkey_devicemeta_json.txt
+# 2. Send FastPairAccountDevicesMetadata for PixelBuds-A to FastPairTestDataCache
+# ./fast_pair_data_provider_shell.sh -d=../test_data/fastpair/pixelbuds-a_account_devicemeta_json.txt
+# 3. Send FastPairAntispoofKeyDeviceMetadata for Provider Simulator to FastPairTestDataCache
+# ./fast_pair_data_provider_shell.sh -m=00000c -a=../test_data/fastpair/simulator_antispoofkey_devicemeta_json.txt
+# 4. Send FastPairAccountDevicesMetadata for Provider Simulator to FastPairTestDataCache
+# ./fast_pair_data_provider_shell.sh -d=../test_data/fastpair/simulator_account_devicemeta_json.txt
+# 5. Clear FastPairTestDataCache
+# ./fast_pair_data_provider_shell.sh -c
+#
+# Check logcat:
+# adb logcat FastPairTestDataManager:* FastPairTestDataProviderService:* *:S
+
+for i in "$@"; do
+  case $i in
+    -a=*|--ask=*)
+      ASK_FILE="${i#*=}"
+      shift # past argument=value
+      ;;
+    -m=*|--model=*)
+      MODEL_ID="${i#*=}"
+      shift # past argument=value
+      ;;
+    -d=*|--adm=*)
+      ADM_FILE="${i#*=}"
+      shift # past argument=value
+      ;;
+    -c)
+      CLEAR="true"
+      shift # past argument
+      ;;
+    -*|--*)
+      echo "Unknown option $i"
+      exit 1
+      ;;
+    *)
+      ;;
+  esac
+done
+
+readonly ACTION_BASE="android.nearby.fastpair.seeker.action"
+readonly ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA="$ACTION_BASE.ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA"
+readonly ACTION_SEND_ACCOUNT_KEY_DEVICE_METADATA="$ACTION_BASE.ACCOUNT_KEY_DEVICE_METADATA"
+readonly ACTION_RESET_TEST_DATA_CACHE="$ACTION_BASE.RESET"
+readonly DATA_JSON_STRING_KEY="json"
+readonly DATA_MODEL_ID_STRING_KEY="modelId"
+
+if [[ -n "${ASK_FILE}" ]] && [[ -n "${MODEL_ID}" ]]; then
+  echo "Sending AntispoofKeyDeviceMetadata for model ${MODEL_ID} to the FastPairTestDataCache..."
+  ASK_JSON_TEXT=$(tr -d '\n' < "$ASK_FILE")
+  CMD="am broadcast -a $ACTION_SEND_ANTISPOOF_KEY_DEVICE_METADATA "
+  CMD+="-e $DATA_MODEL_ID_STRING_KEY '$MODEL_ID' "
+  CMD+="-e $DATA_JSON_STRING_KEY '\"'$ASK_JSON_TEXT'\"'"
+  CMD="adb shell \"$CMD\""
+  echo "$CMD" && eval "$CMD"
+fi
+
+if [ -n "${ADM_FILE}" ]; then
+  echo "Sending AccountKeyDeviceMetadata to the FastPairTestDataCache..."
+  ADM_JSON_TEXT=$(tr -d '\n' < "$ADM_FILE")
+  CMD="am broadcast -a $ACTION_SEND_ACCOUNT_KEY_DEVICE_METADATA "
+  CMD+="-e $DATA_JSON_STRING_KEY '\"'$ADM_JSON_TEXT'\"'"
+  CMD="adb shell \"$CMD\""
+  echo "$CMD" && eval "$CMD"
+fi
+
+if [ -n "${CLEAR}" ]; then
+  echo "Cleaning FastPairTestDataCache..."
+  CMD="adb shell am broadcast -a $ACTION_RESET_TEST_DATA_CACHE"
+  echo "$CMD" && eval "$CMD"
+fi
diff --git a/nearby/tests/robotests/Android.bp b/nearby/tests/robotests/Android.bp
new file mode 100644
index 0000000..56c0107
--- /dev/null
+++ b/nearby/tests/robotests/Android.bp
@@ -0,0 +1,56 @@
+// 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.
+
+//############################################
+// Nearby Robolectric test target. #
+//############################################
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_robolectric_test {
+    name: "NearbyRoboTests",
+    srcs: ["src/**/*.java"],
+    instrumentation_for: "NearbyFakeTestApp",
+    java_resource_dirs: ["config"],
+
+    libs: [
+        "android-support-annotations",
+        "services.core",
+    ],
+
+    static_libs: [
+        "androidx.test.core",
+        "androidx.core_core",
+        "androidx.annotation_annotation",
+        "androidx.legacy_legacy-support-v4",
+        "androidx.recyclerview_recyclerview",
+        "androidx.preference_preference",
+        "androidx.appcompat_appcompat",
+        "androidx.lifecycle_lifecycle-runtime",
+        "androidx.mediarouter_mediarouter-nodeps",
+        "error_prone_annotations",
+        "mockito-robolectric-prebuilt",
+        "service-nearby-pre-jarjar",
+        "truth-prebuilt",
+        "robolectric_android-all-stub",
+        "Robolectric_all-target",
+    ],
+
+    test_options: {
+        // timeout in seconds.
+        timeout: 36000,
+    },
+}
diff --git a/nearby/tests/robotests/AndroidManifest.xml b/nearby/tests/robotests/AndroidManifest.xml
new file mode 100644
index 0000000..25376cf
--- /dev/null
+++ b/nearby/tests/robotests/AndroidManifest.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2018 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="com.android.server.nearby.common.bluetooth.fastpair.test">
+</manifest>
diff --git a/nearby/tests/robotests/config/robolectric.properties b/nearby/tests/robotests/config/robolectric.properties
new file mode 100644
index 0000000..932de7d
--- /dev/null
+++ b/nearby/tests/robotests/config/robolectric.properties
@@ -0,0 +1,16 @@
+#
+# Copyright (C) 2021 Google Inc.
+#
+# 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.
+#
+sdk=NEWEST_SDK
\ No newline at end of file
diff --git a/nearby/tests/robotests/fake_app/Android.bp b/nearby/tests/robotests/fake_app/Android.bp
new file mode 100644
index 0000000..707b38f
--- /dev/null
+++ b/nearby/tests/robotests/fake_app/Android.bp
@@ -0,0 +1,26 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+    name: "NearbyFakeTestApp",
+    srcs: ["*.java"],
+    platform_apis: true,
+    optimize: {
+        enabled: false,
+    },
+}
diff --git a/nearby/tests/robotests/fake_app/AndroidManifest.xml b/nearby/tests/robotests/fake_app/AndroidManifest.xml
new file mode 100644
index 0000000..fdb5390
--- /dev/null
+++ b/nearby/tests/robotests/fake_app/AndroidManifest.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.server.nearby" />
diff --git a/nearby/tests/robotests/fake_app/Empty.java b/nearby/tests/robotests/fake_app/Empty.java
new file mode 100644
index 0000000..96619d5
--- /dev/null
+++ b/nearby/tests/robotests/fake_app/Empty.java
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Bluelet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Bluelet.java
new file mode 100644
index 0000000..182fde7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Bluelet.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower;
+
+import android.os.ParcelUuid;
+
+/**
+ * User interface for mocking and simulation of a Bluetooth device.
+ */
+public interface Bluelet {
+
+    /**
+     * See {@link #setCreateBondOutcome}.
+     */
+    enum CreateBondOutcome {
+        SUCCESS,
+        FAILURE,
+        TIMEOUT
+    }
+
+    /**
+     * See {@link #setIoCapabilities}. Note that Bluetooth specifies a few more choices, but this is
+     * all DeviceShadower currently supports.
+     */
+    enum IoCapabilities {
+        NO_INPUT_NO_OUTPUT,
+        DISPLAY_YES_NO,
+        KEYBOARD_ONLY
+    }
+
+    /**
+     * See {@link #setFetchUuidsTiming}.
+     */
+    enum FetchUuidsTiming {
+        BEFORE_BONDING,
+        AFTER_BONDING,
+        NEVER
+    }
+
+    /**
+     * Set the initial state of the local Bluetooth adapter at the beginning of the test.
+     * <p>This method is not associated with broadcast event and is intended to be called at the
+     * beginning of the test. Allowed states:
+     *
+     * @see android.bluetooth.BluetoothAdapter#STATE_OFF
+     * @see android.bluetooth.BluetoothAdapter#STATE_ON
+     * </p>
+     */
+    Bluelet setAdapterInitialState(int state) throws IllegalArgumentException;
+
+    /**
+     * Set the bluetooth class of the local Bluetooth device at the beginning of the test.
+     * <p>
+     *
+     * @see android.bluetooth.BluetoothClass.Device
+     * @see android.bluetooth.BluetoothClass.Service
+     */
+    Bluelet setBluetoothClass(int bluetoothClass);
+
+    /**
+     * Set the scan mode of the local Bluetooth device at the beginning of the test.
+     */
+    Bluelet setScanMode(int scanMode);
+
+    /**
+     * Set the Bluetooth profiles supported by this device (e.g. A2DP Sink).
+     */
+    Bluelet setProfileUuids(ParcelUuid... profileUuids);
+
+    /**
+     * Makes bond attempts with this device succeed or fail.
+     *
+     * @param failureReason Ignored unless outcome is {@link CreateBondOutcome#FAILURE}. This is
+     * delivered in the intent that indicates bond state has changed to BOND_NONE. Values:
+     * https://cs.corp.google.com/android/frameworks/base/core/java/android/bluetooth/BluetoothDevice.java?rcl=38d9ee4cd661c10e012f71051d23644c65607eed&l=472
+     */
+    Bluelet setCreateBondOutcome(CreateBondOutcome outcome, int failureReason);
+
+    /**
+     * Sets the IO capabilities of this device. When bonding, a device states its IO capabilities in
+     * the pairing request. The pairing variant used depends on the IO capabilities of both devices
+     * (e.g. Just Works is the only available option for a NoInputNoOutput device, while Numeric
+     * Comparison aka Passkey Confirmation is used if both devices have a display and the ability to
+     * confirm/deny).
+     *
+     * @see <a href="https://blog.bluetooth.com/bluetooth-pairing-part-4">Bluetooth blog</a>
+     */
+    Bluelet setIoCapabilities(IoCapabilities ioCapabilities);
+
+    /**
+     * Make the device refuse connections. By default, connections are accepted.
+     *
+     * @param refuse Connections are refused if True.
+     */
+    Bluelet setRefuseConnections(boolean refuse);
+
+    /**
+     * Make the device refuse GATT connections. By default. connections are accepted.
+     *
+     * @param refuse GATT connections are refused if true.
+     */
+    Bluelet setRefuseGattConnections(boolean refuse);
+
+    /**
+     * When to send the ACTION_UUID broadcast. This can be {@link FetchUuidsTiming#BEFORE_BONDING},
+     * {@link FetchUuidsTiming#AFTER_BONDING}, or {@link FetchUuidsTiming#NEVER}. The default is
+     * {@link FetchUuidsTiming#AFTER_BONDING}.
+     */
+    Bluelet setFetchUuidsTiming(FetchUuidsTiming fetchUuidsTiming);
+
+    /**
+     * Adds a bonded device to the BluetoothAdapter.
+     */
+    Bluelet addBondedDevice(String address);
+
+    /**
+     * Enables the CVE-2019-2225 represents that the pairing variant will switch from Just Works to
+     * Consent when local device's io capability is Display Yes/No and remote is NoInputNoOutput.
+     *
+     * @see <a href="https://source.android.com/security/bulletin/2019-12-01#system">the security
+     * bulletin at 2019-12-01</a>
+     */
+    Bluelet enableCVE20192225(boolean value);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironment.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironment.java
new file mode 100644
index 0000000..513d649
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironment.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower;
+
+import com.android.libraries.testing.deviceshadower.Enums.Distance;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+
+/**
+ * Environment to setup and config Bluetooth unit test.
+ */
+public class DeviceShadowEnvironment {
+
+    private static final String TAG = "DeviceShadowEnvironment";
+    private static final long RESET_TIMEOUT_MILLIS = 3000;
+
+    private static boolean sIsInitialized = false;
+
+    private DeviceShadowEnvironment() {
+    }
+
+    public static void init() {
+        sIsInitialized = true;
+        DeviceShadowEnvironmentImpl.reset();
+    }
+
+    public static void reset() {
+        sIsInitialized = false;
+
+        // Order matters because each steps check and manipulate internal objects in order.
+        // Wait Scheduler and executors complete, and shut down executors.
+        DeviceShadowEnvironmentImpl.await(RESET_TIMEOUT_MILLIS);
+
+        // Throw RuntimeException if there is any internal exceptions.
+        DeviceShadowEnvironmentImpl.checkInternalExceptions();
+
+        // Clear internal exceptions, and devicelets.
+        DeviceShadowEnvironmentImpl.reset();
+    }
+
+    public static boolean await(long timeoutMillis) {
+        return DeviceShadowEnvironmentImpl.await(timeoutMillis);
+    }
+
+    public static Devicelet addDevice(final String address) {
+        return DeviceShadowEnvironmentImpl.addDevice(address);
+    }
+
+    public static void removeDevice(String address) {
+        DeviceShadowEnvironmentImpl.removeDevice(address);
+    }
+
+    public static void setLocalDevice(final String address) {
+        DeviceShadowEnvironmentImpl.setLocalDevice(address);
+    }
+
+    public static void putNear(String address1, String address2) {
+        DeviceShadowEnvironmentImpl.setDistance(address1, address2, Distance.NEAR);
+    }
+
+    public static void setDistance(String address1, String address2, Distance distance) {
+        DeviceShadowEnvironmentImpl.setDistance(address1, address2, distance);
+    }
+
+    public static Future<Void> run(final String address, final Runnable snippet) {
+        return run(
+                address,
+                () -> {
+                    snippet.run();
+                    return null;
+                });
+    }
+
+    public static <T> Future<T> run(final String address, final Callable<T> snippet) {
+        return DeviceShadowEnvironmentImpl.run(address, snippet);
+    }
+
+    /* package */
+    static boolean isInitialized() {
+        return sIsInitialized;
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironmentInternal.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironmentInternal.java
new file mode 100644
index 0000000..a5f8e6d
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironmentInternal.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsContentProvider;
+
+/**
+ * Internal interface for device shadower.
+ */
+public class DeviceShadowEnvironmentInternal {
+
+    /**
+     * Set an interruptible point to tested code.
+     * <p>
+     * This should only make changes when DeviceShadowEnvironment initialized, which means only in
+     * test cases.
+     */
+    public static void setInterruptibleBluetooth(int identifier) {
+        if (DeviceShadowEnvironment.isInitialized()) {
+            assert identifier > 0;
+            DeviceShadowEnvironmentImpl.setInterruptibleBluetooth(identifier);
+        }
+    }
+
+    /**
+     * Mark all bluetooth operation broken after identifier in tested code.
+     */
+    public static void interruptBluetooth(String address, int identifier) {
+        DeviceShadowEnvironmentImpl.interruptBluetooth(address, identifier);
+    }
+
+    /**
+     * Return SMS content provider to be registered by robolectric context.
+     */
+    public static Class<SmsContentProvider> getSmsContentProviderClass() {
+        return SmsContentProvider.class;
+    }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Devicelet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Devicelet.java
new file mode 100644
index 0000000..bf31ead
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Devicelet.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower;
+
+/**
+ * Devicelet is the handler to operate shadowed device objects in DeviceShadower.
+ */
+public interface Devicelet {
+
+    Bluelet bluetooth();
+
+    Nfclet nfc();
+
+    Smslet sms();
+
+    String getAddress();
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Enums.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Enums.java
new file mode 100644
index 0000000..9eb3514
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Enums.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower;
+
+/**
+ * Contains Enums used by DeviceShadower in interface and internally.
+ */
+public interface Enums {
+
+    /**
+     * Represents vague distance between two devicelets.
+     */
+    enum Distance {
+        NEAR,
+        MID,
+        FAR,
+        AWAY,
+    }
+
+    /**
+     * Abstract base interface for operations.
+     */
+    interface Operation {
+
+    }
+
+    /**
+     * NFC operations.
+     */
+    enum NfcOperation implements Operation {
+        GET_ADAPTER,
+        ENABLE,
+        DISABLE,
+    }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Nfclet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Nfclet.java
new file mode 100644
index 0000000..4b00f24
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Nfclet.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower;
+
+/**
+ * Interface of Nfclet
+ */
+public interface Nfclet {
+
+    Nfclet setInitialState(int state);
+
+    Nfclet setInterruptOperation(Enums.NfcOperation operation);
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Smslet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Smslet.java
new file mode 100644
index 0000000..483fab6
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Smslet.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower;
+
+import android.net.Uri;
+
+/**
+ * Interface of Smslet
+ */
+public interface Smslet {
+
+    Smslet addSms(Uri uri, String body);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetooth.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetooth.java
new file mode 100644
index 0000000..be8390e
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetooth.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 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 android.bluetooth;
+
+import android.content.AttributionSource;
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelUuid;
+
+/**
+ * Fake interface replacement for hidden IBluetooth class
+ */
+public interface IBluetooth {
+
+    // Bluetooth settings.
+    String getAddress();
+
+    String getName();
+
+    boolean setName(String name);
+
+    // Remote device properties.
+    int getRemoteClass(BluetoothDevice device);
+
+    String getRemoteName(BluetoothDevice device);
+
+    int getRemoteType(BluetoothDevice device, AttributionSource attributionSource);
+
+    ParcelUuid[] getRemoteUuids(BluetoothDevice device);
+
+    boolean fetchRemoteUuids(BluetoothDevice device);
+
+    // Bluetooth discovery.
+    int getScanMode();
+
+    boolean setScanMode(int mode, int duration);
+
+    int getDiscoverableTimeout();
+
+    boolean setDiscoverableTimeout(int timeout);
+
+    boolean startDiscovery();
+
+    boolean cancelDiscovery();
+
+    boolean isDiscovering();
+
+    // Adapter state.
+    boolean isEnabled();
+
+    int getState();
+
+    boolean enable();
+
+    boolean disable();
+
+    // Rfcomm sockets.
+    ParcelFileDescriptor connectSocket(BluetoothDevice device, int type, ParcelUuid uuid,
+            int port, int flag);
+
+    ParcelFileDescriptor createSocketChannel(int type, String serviceName, ParcelUuid uuid,
+            int port, int flag);
+
+    // BLE settings.
+    /* SINCE SDK 21 */ boolean isMultiAdvertisementSupported();
+
+    /* SINCE SDK 22 */ boolean isPeripheralModeSupported();
+
+    /* SINCE SDK 21 */  boolean isOffloadedFilteringSupported();
+
+    // Bonding (pairing).
+    int getBondState(BluetoothDevice device, AttributionSource attributionSource);
+
+    boolean createBond(BluetoothDevice device, int transport, OobData remoteP192Data,
+            OobData remoteP256Data, AttributionSource attributionSource);
+
+    boolean setPairingConfirmation(BluetoothDevice device, boolean accept,
+            AttributionSource attributionSource);
+
+    boolean setPasskey(BluetoothDevice device, int passkey);
+
+    boolean cancelBondProcess(BluetoothDevice device);
+
+    boolean removeBond(BluetoothDevice device);
+
+    BluetoothDevice[] getBondedDevices();
+
+    // Connecting to profiles.
+    int getAdapterConnectionState();
+
+    int getProfileConnectionState(int profile);
+
+    // Access permissions
+    int getPhonebookAccessPermission(BluetoothDevice device);
+
+    boolean setPhonebookAccessPermission(BluetoothDevice device, int value);
+
+    int getMessageAccessPermission(BluetoothDevice device);
+
+    boolean setMessageAccessPermission(BluetoothDevice device, int value);
+
+    int getSimAccessPermission(BluetoothDevice device);
+
+    boolean setSimAccessPermission(BluetoothDevice device, int value);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGatt.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGatt.java
new file mode 100644
index 0000000..16e4f01
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGatt.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 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 android.bluetooth;
+
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.os.ParcelUuid;
+
+import java.util.List;
+
+/**
+ * Fake interface replacement for IBluetoothGatt
+ * TODO(b/200231384): include >=N interface.
+ */
+public interface IBluetoothGatt {
+
+    /* ONLY SDK 23 */
+    void startScan(int appIf, boolean isServer, ScanSettings settings,
+            List<ScanFilter> filters, List<?> scanStorages, String callPackage);
+
+    /* ONLY SDK 21 */
+    void startScan(int appIf, boolean isServer, ScanSettings settings,
+            List<ScanFilter> filters, List<?> scanStorages);
+
+    /* SINCE SDK 21 */
+    void stopScan(int appIf, boolean isServer);
+
+    /* SINCE SDK 21 */
+    void startMultiAdvertising(
+            int appIf, AdvertiseData advertiseData, AdvertiseData scanResponse,
+            AdvertiseSettings settings);
+
+    /* SINCE SDK 21 */
+    void stopMultiAdvertising(int appIf);
+
+    /* SINCE SDK 21 */
+    void registerClient(ParcelUuid appId, IBluetoothGattCallback callback);
+
+    /* SINCE SDK 21 */
+    void unregisterClient(int clientIf);
+
+    /* SINCE SDK 21 */
+    void clientConnect(int clientIf, String address, boolean isDirect, int transport);
+
+    /* SINCE SDK 21 */
+    void clientDisconnect(int clientIf, String address);
+
+    /* SINCE SDK 21 */
+    void discoverServices(int clientIf, String address);
+
+    /* SINCE SDK 21 */
+    void readCharacteristic(int clientIf, String address, int srvcType,
+            int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+            int authReq);
+
+    /* SINCE SDK 21 */
+    void writeCharacteristic(int clientIf, String address, int srvcType,
+            int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+            int writeType, int authReq, byte[] value);
+
+    /* SINCE SDK 21 */
+    void readDescriptor(int clientIf, String address, int srvcType,
+            int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+            int descrInstanceId, ParcelUuid descrUuid, int authReq);
+
+    /* SINCE SDK 21 */
+    void writeDescriptor(int clientIf, String address, int srvcType,
+            int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+            int descrInstanceId, ParcelUuid descrId, int writeType, int authReq, byte[] value);
+
+    /* SINCE SDK 21 */
+    void registerForNotification(int clientIf, String address, int srvcType,
+            int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+            boolean enable);
+
+    /* SINCE SDK 21 */
+    void registerServer(ParcelUuid appId, IBluetoothGattServerCallback callback);
+
+    /* SINCE SDK 21 */
+    void unregisterServer(int serverIf);
+
+    /* SINCE SDK 21 */
+    void serverConnect(int servertIf, String address, boolean isDirect, int transport);
+
+    /* SINCE SDK 21 */
+    void serverDisconnect(int serverIf, String address);
+
+    /* SINCE SDK 21 */
+    void beginServiceDeclaration(int serverIf, int srvcType, int srvcInstanceId, int minHandles,
+            ParcelUuid srvcId, boolean advertisePreferred);
+
+    /* SINCE SDK 21 */
+    void addIncludedService(int serverIf, int srvcType, int srvcInstanceId, ParcelUuid srvcId);
+
+    /* SINCE SDK 21 */
+    void addCharacteristic(int serverIf, ParcelUuid charId, int properties, int permissions);
+
+    /* SINCE SDK 21 */
+    void addDescriptor(int serverIf, ParcelUuid descId, int permissions);
+
+    /* SINCE SDK 21 */
+    void endServiceDeclaration(int serverIf);
+
+    /* SINCE SDK 21 */
+    void removeService(int serverIf, int srvcType, int srvcInstanceId, ParcelUuid srvcId);
+
+    /* SINCE SDK 21 */
+    void clearServices(int serverIf);
+
+    /* SINCE SDK 21 */
+    void sendResponse(int serverIf, String address, int requestId,
+            int status, int offset, byte[] value);
+
+    /* SINCE SDK 21 */
+    void sendNotification(int serverIf, String address, int srvcType,
+            int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+            boolean confirm, byte[] value);
+
+    /* SINCE SDK 21 */
+    void configureMTU(int clientIf, String address, int mtu);
+
+    /* SINCE SDK 21 */
+    void connectionParameterUpdate(int clientIf, String address, int connectionPriority);
+
+    void disconnectAll();
+
+    List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattCallback.java
new file mode 100644
index 0000000..b29369b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattCallback.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 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 android.bluetooth;
+
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanResult;
+import android.os.ParcelUuid;
+
+/**
+ * Fake interface replacement for IBluetoothGattCallback
+ * TODO(b/200231384): include >=N interface.
+ */
+public interface IBluetoothGattCallback {
+
+    /* SINCE SDK 21 */
+    void onClientRegistered(int status, int clientIf);
+
+    /* SINCE SDK 21 */
+    void onClientConnectionState(int status, int clientIf, boolean connected, String address);
+
+    /* ONLY SDK 19 */
+    void onScanResult(String address, int rssi, byte[] advData);
+
+    /* SINCE SDK 21 */
+    void onScanResult(ScanResult scanResult);
+
+    /* SINCE SDK 21 */
+    void onGetService(String address, int srvcType, int srvcInstId, ParcelUuid srvcUuid);
+
+    /* SINCE SDK 21 */
+    void onGetIncludedService(String address, int srvcType, int srvcInstId,
+            ParcelUuid srvcUuid, int inclSrvcType,
+            int inclSrvcInstId, ParcelUuid inclSrvcUuid);
+
+    /* SINCE SDK 21 */
+    void onGetCharacteristic(String address, int srvcType,
+            int srvcInstId, ParcelUuid srvcUuid,
+            int charInstId, ParcelUuid charUuid,
+            int charProps);
+
+    /* SINCE SDK 21 */
+    void onGetDescriptor(String address, int srvcType,
+            int srvcInstId, ParcelUuid srvcUuid,
+            int charInstId, ParcelUuid charUuid,
+            int descrInstId, ParcelUuid descrUuid);
+
+    /* SINCE SDK 21 */
+    void onSearchComplete(String address, int status);
+
+    /* SINCE SDK 21 */
+    void onCharacteristicRead(String address, int status, int srvcType,
+            int srvcInstId, ParcelUuid srvcUuid,
+            int charInstId, ParcelUuid charUuid,
+            byte[] value);
+
+    /* SINCE SDK 21 */
+    void onCharacteristicWrite(String address, int status, int srvcType,
+            int srvcInstId, ParcelUuid srvcUuid,
+            int charInstId, ParcelUuid charUuid);
+
+    /* SINCE SDK 21 */
+    void onExecuteWrite(String address, int status);
+
+    /* SINCE SDK 21 */
+    void onDescriptorRead(String address, int status, int srvcType,
+            int srvcInstId, ParcelUuid srvcUuid,
+            int charInstId, ParcelUuid charUuid,
+            int descrInstId, ParcelUuid descrUuid,
+            byte[] value);
+
+    /* SINCE SDK 21 */
+    void onDescriptorWrite(String address, int status, int srvcType,
+            int srvcInstId, ParcelUuid srvcUuid,
+            int charInstId, ParcelUuid charUuid,
+            int descrInstId, ParcelUuid descrUuid);
+
+    /* SINCE SDK 21 */
+    void onNotify(String address, int srvcType,
+            int srvcInstId, ParcelUuid srvcUuid,
+            int charInstId, ParcelUuid charUuid,
+            byte[] value);
+
+    /* SINCE SDK 21 */
+    void onReadRemoteRssi(String address, int rssi, int status);
+
+    /* SDK 21 */
+    void onMultiAdvertiseCallback(int status, boolean isStart,
+            AdvertiseSettings advertiseSettings);
+
+    /* SDK 21 */
+    void onConfigureMTU(String address, int mtu, int status);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattServerCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattServerCallback.java
new file mode 100644
index 0000000..10b91bb
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattServerCallback.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 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 android.bluetooth;
+
+import android.os.ParcelUuid;
+
+/**
+ * Fake interface of internal IBluetoothGattServerCallback.
+ */
+public interface IBluetoothGattServerCallback {
+
+    /* SINCE SDK 21 */
+    void onServerRegistered(int status, int serverIf);
+
+    /* SINCE SDK 21 */
+    void onScanResult(String address, int rssi, byte[] advData);
+
+    /* SINCE SDK 21 */
+    void onServerConnectionState(int status, int serverIf, boolean connected, String address);
+
+    /* SINCE SDK 21 */
+    void onServiceAdded(int status, int srvcType, int srvcInstId, ParcelUuid srvcId);
+
+    /* SINCE SDK 21 */
+    void onCharacteristicReadRequest(String address, int transId, int offset, boolean isLong,
+            int srvcType, int srvcInstId, ParcelUuid srvcId, int charInstId, ParcelUuid charId);
+
+    /* SINCE SDK 21 */
+    void onDescriptorReadRequest(String address, int transId, int offset, boolean isLong,
+            int srvcType, int srvcInstId, ParcelUuid srvcId,
+            int charInstId, ParcelUuid charId, ParcelUuid descrId);
+
+    /* SINCE SDK 21 */
+    void onCharacteristicWriteRequest(String address, int transId, int offset, int length,
+            boolean isPrep, boolean needRsp, int srvcType, int srvcInstId, ParcelUuid srvcId,
+            int charInstId, ParcelUuid charId, byte[] value);
+
+    /* SINCE SDK 21 */
+    void onDescriptorWriteRequest(String address, int transId, int offset, int length,
+            boolean isPrep, boolean needRsp, int srvcType, int srvcInstId, ParcelUuid srvcId,
+            int charInstId, ParcelUuid charId, ParcelUuid descrId, byte[] value);
+
+    /* SINCE SDK 21 */
+    void onExecuteWrite(String address, int transId, boolean execWrite);
+
+    /* SINCE SDK 21 */
+    void onNotificationSent(String address, int status);
+
+    /* SINCE SDK 22 */
+    void onMtuChanged(String address, int mtu);
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManager.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManager.java
new file mode 100644
index 0000000..6bb2209
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManager.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 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.
+ */
+
+/**
+ * Intentionally in package android.bluetooth to fake existing interface in Android.
+ */
+package android.bluetooth;
+
+/**
+ * Fake interface for IBluetoothManager.
+ */
+public interface IBluetoothManager {
+
+    boolean enable();
+
+    boolean disable(boolean persist);
+
+    String getAddress();
+
+    String getName();
+
+    IBluetooth registerAdapter(IBluetoothManagerCallback callback);
+
+    IBluetoothGatt getBluetoothGatt();
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManagerCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManagerCallback.java
new file mode 100644
index 0000000..f39b82f
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManagerCallback.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 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 android.bluetooth;
+
+/**
+ * Fake interface replacement for hidden IBluetoothManagerCallback class
+ */
+public interface IBluetoothManagerCallback {
+
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/BeamShareData.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/BeamShareData.java
new file mode 100644
index 0000000..5357a9b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/BeamShareData.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 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 android.nfc;
+
+import android.net.Uri;
+import android.os.UserHandle;
+
+/**
+ * Fake BeamShareData.
+ */
+public class BeamShareData {
+
+    public NdefMessage ndefMessage;
+    public Uri[] uris;
+    public UserHandle userHandle;
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/IAppCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/IAppCallback.java
new file mode 100644
index 0000000..7b62f19
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/IAppCallback.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 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 android.nfc;
+
+/**
+ * Fake interface for nfc service.
+ */
+public interface IAppCallback {
+
+    /* M */ void onNdefPushComplete(byte peerLlcpVersion);
+
+    /* M */ BeamShareData createBeamShareData(byte peerLlcpVersion);
+
+    /* L */ void onNdefPushComplete();
+
+    /* L */ BeamShareData createBeamShareData();
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/INfcAdapter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/INfcAdapter.java
new file mode 100644
index 0000000..08acdbc
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/INfcAdapter.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 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 android.nfc;
+
+/**
+ * Fake interface of INfcAdapter
+ */
+public interface INfcAdapter {
+
+    void setAppCallback(IAppCallback callback);
+
+    boolean enable();
+
+    boolean disable(boolean saveState);
+
+    int getState();
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleAdvertiser.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleAdvertiser.java
new file mode 100644
index 0000000..f3328c8
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleAdvertiser.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import com.google.common.base.Preconditions;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+
+/**
+ * Helper class to operate a device as BLE advertiser.
+ */
+public class BleAdvertiser {
+
+    private static final String TAG = "BleAdvertiser";
+
+    private static final int DEFAULT_MODE = AdvertiseSettings.ADVERTISE_MODE_BALANCED;
+    private static final int DEFAULT_TX_POWER_LEVEL = AdvertiseSettings.ADVERTISE_TX_POWER_HIGH;
+    private static final boolean DEFAULT_CONNECTABLE = true;
+    private static final int DEFAULT_TIMEOUT = 0;
+
+
+    /**
+     * Callback of {@link BleAdvertiser}.
+     */
+    public interface Callback {
+
+        void onStartFailure(String address, int errorCode);
+
+        void onStartSuccess(String address, AdvertiseSettings settingsInEffect);
+    }
+
+    /**
+     * Builder class of {@link BleAdvertiser}.
+     */
+    public static final class Builder {
+
+        private final String mAddress;
+        private final Callback mCallback;
+        private AdvertiseSettings mSettings = defaultSettings();
+        private AdvertiseData mData;
+        private AdvertiseData mResponse;
+
+        public Builder(String address, Callback callback) {
+            this.mAddress = Preconditions.checkNotNull(address);
+            this.mCallback = Preconditions.checkNotNull(callback);
+        }
+
+        public Builder setAdvertiseSettings(AdvertiseSettings settings) {
+            this.mSettings = settings;
+            return this;
+        }
+
+        public Builder setAdvertiseData(AdvertiseData data) {
+            this.mData = data;
+            return this;
+        }
+
+        public Builder setResponseData(AdvertiseData response) {
+            this.mResponse = response;
+            return this;
+        }
+
+        public BleAdvertiser build() {
+            return new BleAdvertiser(mAddress, mCallback, mSettings, mData, mResponse);
+        }
+    }
+
+    private static AdvertiseSettings defaultSettings() {
+        return new AdvertiseSettings.Builder()
+                .setAdvertiseMode(DEFAULT_MODE)
+                .setConnectable(DEFAULT_CONNECTABLE)
+                .setTimeout(DEFAULT_TIMEOUT)
+                .setTxPowerLevel(DEFAULT_TX_POWER_LEVEL).build();
+    }
+
+    private final String mAddress;
+    private final Callback mCallback;
+    private final AdvertiseSettings mSettings;
+    private final AdvertiseData mData;
+    private final AdvertiseData mResponse;
+    private final CountDownLatch mStartAdvertiseLatch;
+    private BluetoothLeAdvertiser mAdvertiser;
+
+    private BleAdvertiser(String address, Callback callback, AdvertiseSettings settings,
+            AdvertiseData data, AdvertiseData response) {
+        this.mAddress = address;
+        this.mCallback = callback;
+        this.mSettings = settings;
+        this.mData = data;
+        this.mResponse = response;
+        mStartAdvertiseLatch = new CountDownLatch(1);
+        DeviceShadowEnvironment.addDevice(address).bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+    }
+
+    /**
+     * Starts advertising.
+     */
+    public Future<Void> start() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mAdvertiser = BluetoothAdapter.getDefaultAdapter().getBluetoothLeAdvertiser();
+                mAdvertiser.startAdvertising(mSettings, mData, mResponse, mAdvertiseCallback);
+            }
+        });
+    }
+
+    /**
+     * Stops advertising.
+     */
+    public Future<Void> stop() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mAdvertiser.stopAdvertising(mAdvertiseCallback);
+            }
+        });
+    }
+
+    public void waitTillAdvertiseCompleted() {
+        try {
+            mStartAdvertiseLatch.await();
+        } catch (InterruptedException e) {
+            Log.w(TAG, mAddress + " fails to wait till advertise completed: ", e);
+        }
+    }
+
+    private final AdvertiseCallback mAdvertiseCallback = new AdvertiseCallback() {
+        @Override
+        public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+            Log.v(TAG,
+                    String.format("onStartSuccess(settingsInEffect: %s) on %s ", settingsInEffect,
+                            mAddress));
+            mCallback.onStartSuccess(mAddress, settingsInEffect);
+            mStartAdvertiseLatch.countDown();
+        }
+
+        @Override
+        public void onStartFailure(int errorCode) {
+            Log.v(TAG, String.format("onStartFailure(errorCode: %d) on %s", errorCode, mAddress));
+            mCallback.onStartFailure(mAddress, errorCode);
+            mStartAdvertiseLatch.countDown();
+        }
+    };
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleScanner.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleScanner.java
new file mode 100644
index 0000000..6a44c2b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleScanner.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import com.google.common.base.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helper class to operate a device as BLE scanner.
+ */
+public class BleScanner {
+
+    private static final String TAG = "BleScanner";
+
+    private static final int DEFAULT_MODE = ScanSettings.SCAN_MODE_LOW_LATENCY;
+    private static final int DEFAULT_CALLBACK_TYPE = ScanSettings.CALLBACK_TYPE_ALL_MATCHES;
+    private static final long DEFAULT_DELAY = 0L;
+
+    /**
+     * Callback of {@link BleScanner}.
+     */
+    public interface Callback {
+
+        void onScanResult(String address, int callbackType, ScanResult result);
+
+        void onBatchScanResults(String address, List<ScanResult> results);
+
+        void onScanFailed(String address, int errorCode);
+    }
+
+    /**
+     * Builder class of {@link BleScanner}.
+     */
+    public static final class Builder {
+
+        private final String mAddress;
+        private final Callback mCallback;
+        private ScanSettings mSettings = defaultSettings();
+        private List<ScanFilter> mFilters;
+        private int mNumOfExpectedScanCallbacks = 1;
+
+        public Builder(String address, Callback callback) {
+            this.mAddress = Preconditions.checkNotNull(address);
+            this.mCallback = Preconditions.checkNotNull(callback);
+        }
+
+        public Builder setScanSettings(ScanSettings settings) {
+            this.mSettings = settings;
+            return this;
+        }
+
+        public Builder addScanFilter(ScanFilter... filterArgs) {
+            if (this.mFilters == null) {
+                this.mFilters = new ArrayList<>();
+            }
+            for (ScanFilter filter : filterArgs) {
+                this.mFilters.add(filter);
+            }
+            return this;
+        }
+
+        /**
+         * Sets number of expected scan result callback.
+         *
+         * @param num Number of expected scan result callback, default to 1.
+         */
+        public Builder setNumOfExpectedScanCallbacks(int num) {
+            mNumOfExpectedScanCallbacks = num;
+            return this;
+        }
+
+        public BleScanner build() {
+            return new BleScanner(
+                    mAddress, mCallback, mSettings, mFilters, mNumOfExpectedScanCallbacks);
+        }
+    }
+
+    private static ScanSettings defaultSettings() {
+        return new ScanSettings.Builder()
+                .setScanMode(DEFAULT_MODE)
+                .setCallbackType(DEFAULT_CALLBACK_TYPE)
+                .setReportDelay(DEFAULT_DELAY).build();
+    }
+
+    private final String mAddress;
+    private final Callback mCallback;
+    private final ScanSettings mSettings;
+    private final List<ScanFilter> mFilters;
+    private final BlockingQueue<Integer> mScanResultCounts;
+    private int mNumOfExpectedScanCallbacks;
+    private int mNumOfReceivedScanCallbacks;
+    private BluetoothLeScanner mScanner;
+
+    private BleScanner(String address, Callback callback, ScanSettings settings,
+            List<ScanFilter> filters, int numOfExpectedScanResult) {
+        this.mAddress = address;
+        this.mCallback = callback;
+        this.mSettings = settings;
+        this.mFilters = filters;
+        this.mNumOfExpectedScanCallbacks = numOfExpectedScanResult;
+        this.mNumOfReceivedScanCallbacks = 0;
+        this.mScanResultCounts = new LinkedBlockingQueue<>(numOfExpectedScanResult);
+        DeviceShadowEnvironment.addDevice(address).bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+    }
+
+    public Future<Void> start() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mScanner = BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner();
+                mScanner.startScan(mFilters, mSettings, mScanCallback);
+            }
+        });
+    }
+
+    public void waitTillNextScanResult(long timeoutMillis) {
+        Integer result = null;
+        if (mNumOfReceivedScanCallbacks >= mNumOfExpectedScanCallbacks) {
+            return;
+        }
+        try {
+            if (timeoutMillis < 0) {
+                result = mScanResultCounts.take();
+            } else {
+                result = mScanResultCounts.poll(timeoutMillis, TimeUnit.MILLISECONDS);
+            }
+            if (result != null && result >= 0) {
+                mNumOfReceivedScanCallbacks++;
+            }
+            Log.v(TAG, "Scan results: " + result);
+        } catch (InterruptedException e) {
+            Log.w(TAG, mAddress + " fails to wait till next scan result: ", e);
+        }
+    }
+
+    public void waitTillNextScanResult() {
+        waitTillNextScanResult(-1);
+    }
+
+    public void waitTillAllScanResults() {
+        while (mNumOfReceivedScanCallbacks < mNumOfExpectedScanCallbacks) {
+            try {
+                if (mScanResultCounts.take() >= 0) {
+                    mNumOfReceivedScanCallbacks++;
+                }
+            } catch (InterruptedException e) {
+                Log.w(TAG, String.format("%s fails to wait scan result", mAddress), e);
+                return;
+            }
+        }
+    }
+
+    public Future<Void> stop() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mScanner = BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner();
+                mScanner.stopScan(mScanCallback);
+            }
+        });
+    }
+
+    private final ScanCallback mScanCallback = new ScanCallback() {
+        @Override
+        public void onScanResult(int callbackType, ScanResult result) {
+            Log.v(TAG, String.format("onScanResult(callbackType: %d, result: %s) on %s",
+                    callbackType, result, mAddress));
+            mCallback.onScanResult(mAddress, callbackType, result);
+            try {
+                mScanResultCounts.put(1);
+            } catch (InterruptedException e) {
+                // no-op.
+            }
+        }
+
+        @Override
+        public void onBatchScanResults(List<ScanResult> results) {
+            /**** Not supported yet.
+             Log.v(TAG, String.format("onBatchScanResults(results: %s) on %s",
+             Arrays.toString(results.toArray()), address));
+             callback.onBatchScanResults(address, results);
+             try {
+             scanResultCounts.put(results.size());
+             } catch (InterruptedException e) {
+             // no-op.
+             }
+             */
+        }
+
+        @Override
+        public void onScanFailed(int errorCode) {
+            /**** Not supported yet.
+             Log.v(TAG, String.format("onScanFailed(errorCode: %d) on %s", errorCode, address));
+             callback.onScanFailed(address, errorCode);
+             try {
+             scanResultCounts.put(-1);
+             } catch (InterruptedException e) {
+             // no-op.
+             }
+             */
+        }
+    };
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattClient.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattClient.java
new file mode 100644
index 0000000..69e77af
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattClient.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.content.Context;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helper class to operate a device as gatt client.
+ */
+public class BluetoothGattClient {
+
+    private static final String TAG = "BluetoothGattClient";
+    private static final int LATCH_TIMEOUT_MILLIS = 1000;
+
+    /**
+     * Callback of BluetoothGattClient.
+     */
+    public interface Callback {
+
+        void onConnectionStateChange(String address, int status, int newState);
+
+        void onCharacteristicChanged(String address, UUID uuid, byte[] value);
+
+        void onCharacteristicRead(String address, UUID uuid, byte[] value, int status);
+
+        void onCharacteristicWrite(String address, UUID uuid, byte[] value, int status);
+
+        void onDescriptorRead(String address, UUID uuid, byte[] value, int status);
+
+        void onDescriptorWrite(String address, UUID uuid, byte[] value, int status);
+
+        void onServicesDiscovered(
+                UUID[] serviceUuid, UUID[] characteristicUuid, UUID[] descriptorUuid, int status);
+
+        void onConfigureMTU(String address, int mtu, int status);
+    }
+
+    private final String mAddress;
+    private final Callback mCallback;
+    private final Context mContext;
+    private final Map<UUID, BluetoothGattCharacteristic> mCharacteristics = new HashMap<>();
+    private final Map<UUID, BluetoothGattDescriptor> mDescriptors = new HashMap<>();
+    private BluetoothGatt mGatt;
+    private CountDownLatch mConnectionLatch;
+    private CountDownLatch mServiceDiscoverLatch;
+
+    public BluetoothGattClient(String address, Callback callback, Context context) {
+        this.mAddress = address;
+        this.mCallback = callback;
+        this.mContext = context;
+        DeviceShadowEnvironment.addDevice(address).bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+    }
+
+    public Future<Void> connect(final String remoteAddress) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mConnectionLatch = new CountDownLatch(1);
+                mGatt = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(remoteAddress)
+                        .connectGatt(mContext, false /* auto connect */, mGattCallback);
+                try {
+                    mConnectionLatch.await(LATCH_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+                } catch (InterruptedException e) {
+                    // no-op.
+                }
+
+                mServiceDiscoverLatch = new CountDownLatch(1);
+                mGatt.discoverServices();
+                try {
+                    mServiceDiscoverLatch.await(LATCH_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+                } catch (InterruptedException e) {
+                    // no-op.
+                }
+            }
+        });
+    }
+
+    public Future<Void> close() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mGatt.disconnect();
+                mGatt.close();
+            }
+        });
+    }
+
+    public Future<Void> readCharacteristic(final UUID uuid) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mGatt.readCharacteristic(mCharacteristics.get(uuid));
+            }
+        });
+    }
+
+    public Future<Void> setNotification(final UUID uuid) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mGatt.setCharacteristicNotification(mCharacteristics.get(uuid), true);
+            }
+        });
+    }
+
+    public Future<Void> writeCharacteristic(final UUID uuid, final byte[] value) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                BluetoothGattCharacteristic characteristic = mCharacteristics.get(uuid);
+                characteristic.setValue(value);
+                mGatt.writeCharacteristic(characteristic);
+            }
+        });
+    }
+
+    /**
+     * Reads the value of a descriptor with given UUID.
+     *
+     * <p>If different characteristics on the service have the same descriptor, use {@link
+     * BluetoothGattClient#readDescriptor(UUID, UUID)} instead.
+     */
+    public Future<Void> readDescriptor(final UUID uuid) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mGatt.readDescriptor(mDescriptors.get(uuid));
+            }
+        });
+    }
+
+    /**
+     * Reads the descriptor value of the specified characteristic.
+     */
+    public Future<Void> readDescriptor(final UUID descriptorUuid, final UUID characteristicUuid) {
+        return DeviceShadowEnvironment.run(
+                mAddress,
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        mGatt.readDescriptor(
+                                mCharacteristics.get(characteristicUuid)
+                                        .getDescriptor(descriptorUuid));
+                    }
+                });
+    }
+
+    /**
+     * Writes to the descriptor with given UUID.
+     *
+     * <p>If different characteristics on the service have the same descriptor, use {@link
+     * BluetoothGattClient#writeDescriptor(UUID, UUID, byte[])} instead.
+     */
+    public Future<Void> writeDescriptor(final UUID uuid, final byte[] value) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                BluetoothGattDescriptor descriptor = mDescriptors.get(uuid);
+                descriptor.setValue(value);
+                mGatt.writeDescriptor(descriptor);
+            }
+        });
+    }
+
+    /**
+     * Writes to the descriptor of the specified characteristic.
+     */
+    public Future<Void> writeDescriptor(
+            final UUID descriptorUuid, final UUID characteristicUuid, final byte[] value) {
+        return DeviceShadowEnvironment.run(
+                mAddress,
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        BluetoothGattDescriptor descriptor =
+                                mCharacteristics.get(characteristicUuid)
+                                        .getDescriptor(descriptorUuid);
+                        descriptor.setValue(value);
+                        mGatt.writeDescriptor(descriptor);
+                    }
+                });
+    }
+
+    public Future<Void> requestMtu(int mtu) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mGatt.requestMtu(mtu);
+            }
+        });
+    }
+
+    private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() {
+        @Override
+        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+            Log.v(TAG, String.format("onConnectionStateChange(status: %s, newState: %s)",
+                    status, newState));
+            if (mConnectionLatch != null) {
+                mConnectionLatch.countDown();
+            }
+            mCallback.onConnectionStateChange(gatt.getDevice().getAddress(), status, newState);
+        }
+
+        @Override
+        public void onCharacteristicChanged(
+                BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+            Log.v(TAG, String.format("onCharacteristicChanged(characteristic: %s, value: %s)",
+                    characteristic.getUuid(), Arrays.toString(characteristic.getValue())));
+            mCallback.onCharacteristicChanged(
+                    gatt.getDevice().getAddress(), characteristic.getUuid(),
+                    characteristic.getValue());
+        }
+
+        @Override
+        public void onCharacteristicRead(
+                BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+            Log.v(TAG, String.format("onCharacteristicRead(descriptor: %s, status: %s)",
+                    characteristic.getUuid(), status));
+            mCallback.onCharacteristicRead(
+                    gatt.getDevice().getAddress(), characteristic.getUuid(),
+                    characteristic.getValue(),
+                    status);
+        }
+
+        @Override
+        public void onCharacteristicWrite(
+                BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+            Log.v(TAG, String.format("onCharacteristicWrite(descriptor: %s, status: %s)",
+                    characteristic.getUuid(), status));
+            mCallback.onCharacteristicWrite(gatt.getDevice().getAddress(),
+                    characteristic.getUuid(), characteristic.getValue(), status);
+        }
+
+        @Override
+        public void onDescriptorRead(
+                BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+            Log.v(TAG, String.format("onDescriptorRead(descriptor: %s, status: %s)",
+                    descriptor.getUuid(), status));
+            mCallback.onDescriptorRead(
+                    gatt.getDevice().getAddress(), descriptor.getUuid(), descriptor.getValue(),
+                    status);
+        }
+
+        @Override
+        public void onDescriptorWrite(
+                BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+            Log.v(TAG, String.format("onDescriptorWrite(descriptor: %s, status: %s)",
+                    descriptor.getUuid(), status));
+            mCallback.onDescriptorWrite(
+                    gatt.getDevice().getAddress(), descriptor.getUuid(), descriptor.getValue(),
+                    status);
+        }
+
+        @Override
+        public synchronized void onServicesDiscovered(BluetoothGatt gatt, int status) {
+            Log.v(TAG, "Discovered service: " + gatt.getServices());
+            List<UUID> serviceUuid = new ArrayList<>();
+            List<UUID> characteristicUuid = new ArrayList<>();
+            List<UUID> descriptorUuid = new ArrayList<>();
+            for (BluetoothGattService service : gatt.getServices()) {
+                serviceUuid.add(service.getUuid());
+                for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
+                    mCharacteristics.put(characteristic.getUuid(), characteristic);
+                    characteristicUuid.add(characteristic.getUuid());
+                    for (BluetoothGattDescriptor descriptor : characteristic.getDescriptors()) {
+                        mDescriptors.put(descriptor.getUuid(), descriptor);
+                        descriptorUuid.add(descriptor.getUuid());
+                    }
+                }
+            }
+
+            Collections.sort(serviceUuid);
+            Collections.sort(characteristicUuid);
+            Collections.sort(descriptorUuid);
+
+            mCallback.onServicesDiscovered(serviceUuid.toArray(new UUID[serviceUuid.size()]),
+                    characteristicUuid.toArray(new UUID[characteristicUuid.size()]),
+                    descriptorUuid.toArray(new UUID[descriptorUuid.size()]),
+                    status);
+            mServiceDiscoverLatch.countDown();
+        }
+
+        @Override
+        public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+            Log.v(TAG, String.format("onMtuChanged(mtu: %s, status: %s)", mtu, status));
+            mCallback.onConfigureMTU(gatt.getDevice().getAddress(), mtu, status);
+        }
+    };
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattMaster.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattMaster.java
new file mode 100644
index 0000000..e9f364a
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattMaster.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattServer;
+import android.bluetooth.BluetoothGattServerCallback;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothManager;
+import android.content.Context;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.Future;
+
+/**
+ * Helper class to operate a device as gatt server.
+ */
+public class BluetoothGattMaster {
+
+    private static final String TAG = "BluetoothGattMaster";
+
+    /**
+     * Callback of BluetoothGattMaster.
+     */
+    public interface Callback {
+
+        void onConnectionStateChange(String address, int status, int newState);
+
+        void onCharacteristicReadRequest(String address, UUID uuid);
+
+        void onCharacteristicWriteRequest(String address, UUID uuid, byte[] value,
+                boolean preparedWrite, boolean responseNeeded);
+
+        void onDescriptorReadRequest(String address, UUID uuid);
+
+        void onDescriptorWriteRequest(String address, UUID uuid, byte[] value,
+                boolean preparedWrite, boolean responseNeeded);
+
+        void onNotificationSent(String address, int status);
+
+        void onExecuteWrite(String address, boolean execute);
+
+        void onServiceAdded(UUID uuid, int status);
+
+        void onMtuChanged(String address, int mtu);
+    }
+
+    private final String mAddress;
+    private final Callback mCallback;
+    private final Context mContext;
+    private BluetoothGattServer mGattServer;
+    private final Map<UUID, BluetoothGattCharacteristic> mCharacteristics = new HashMap<>();
+
+    public BluetoothGattMaster(String address, Callback callback, Context context) {
+        this.mAddress = address;
+        this.mCallback = callback;
+        this.mContext = context;
+        DeviceShadowEnvironment.addDevice(address).bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+    }
+
+    public Future<Void> start(final BluetoothGattService service) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                BluetoothManager manager = mContext.getSystemService(BluetoothManager.class);
+                mGattServer = manager.openGattServer(mContext, mGattServerCallback);
+                mGattServer.addService(service);
+            }
+        });
+    }
+
+    public Future<Void> stop() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mGattServer.close();
+            }
+        });
+    }
+
+    public Future<Void> notifyCharacteristic(
+            final String remoteAddress, final UUID uuid, final byte[] value,
+            final boolean confirm) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                BluetoothGattCharacteristic characteristic = mCharacteristics.get(uuid);
+                characteristic.setValue(value);
+                mGattServer.notifyCharacteristicChanged(
+                        BluetoothAdapter.getDefaultAdapter().getRemoteDevice(remoteAddress),
+                        characteristic, confirm);
+            }
+        });
+    }
+
+    private BluetoothGattServerCallback mGattServerCallback = new BluetoothGattServerCallback() {
+        @Override
+        public void onConnectionStateChange(BluetoothDevice device, int status, int newState) {
+            String address = device.getAddress();
+            Log.v(TAG, String.format(
+                    "BluetoothGattServerManager.onConnectionStateChange on %s: status %d,"
+                            + " newState %d", address, status, newState));
+            mCallback.onConnectionStateChange(address, status, newState);
+        }
+
+        @Override
+        public void onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset,
+                BluetoothGattCharacteristic characteristic) {
+            String address = device.getAddress();
+            UUID uuid = characteristic.getUuid();
+            Log.v(TAG,
+                    String.format("BluetoothGattServerManager.onCharacteristicReadRequest on %s: "
+                                    + "characteristic %s, request %d, offset %d",
+                            address, uuid, requestId, offset));
+            mCallback.onCharacteristicReadRequest(address, uuid);
+            mGattServer.sendResponse(
+                    device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+                    characteristic.getValue());
+        }
+
+        @Override
+        public void onCharacteristicWriteRequest(BluetoothDevice device, int requestId,
+                BluetoothGattCharacteristic characteristic, boolean preparedWrite,
+                boolean responseNeeded,
+                int offset, byte[] value) {
+            String address = device.getAddress();
+            UUID uuid = characteristic.getUuid();
+            Log.v(TAG,
+                    String.format("BluetoothGattServerManager.onCharacteristicWriteRequest on %s: "
+                                    + "characteristic %s, request %d, offset %d, preparedWrite %b, "
+                                    + "responseNeeded %b",
+                            address, uuid, requestId, offset, preparedWrite, responseNeeded));
+            mCallback.onCharacteristicWriteRequest(address, uuid, value, preparedWrite,
+                    responseNeeded);
+
+            if (responseNeeded) {
+                mGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+                        null);
+            }
+        }
+
+        @Override
+        public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset,
+                BluetoothGattDescriptor descriptor) {
+            String address = device.getAddress();
+            UUID uuid = descriptor.getUuid();
+            Log.v(TAG, String.format("BluetoothGattServerManager.onDescriptorReadRequest on %s: "
+                            + " descriptor %s, requestId %d, offset %d",
+                    address, uuid, requestId, offset));
+            mCallback.onDescriptorReadRequest(address, uuid);
+            mGattServer.sendResponse(
+                    device, requestId, BluetoothGatt.GATT_SUCCESS, offset, descriptor.getValue());
+        }
+
+        @Override
+        public void onDescriptorWriteRequest(BluetoothDevice device, int requestId,
+                BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded,
+                int offset, byte[] value) {
+            String address = device.getAddress();
+            UUID uuid = descriptor.getUuid();
+            Log.v(TAG, String.format("BluetoothGattServerManager.onDescriptorWriteRequest on %s: "
+                            + "descriptor %s, requestId %d, offset %d, preparedWrite %b, "
+                            + "responseNeeded %b",
+                    address, uuid, requestId, offset, preparedWrite, responseNeeded));
+            mCallback.onDescriptorWriteRequest(address, uuid, value, preparedWrite, responseNeeded);
+
+            if (responseNeeded) {
+                mGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+                        null);
+            }
+        }
+
+        @Override
+        public void onNotificationSent(BluetoothDevice device, int status) {
+            String address = device.getAddress();
+            Log.v(TAG,
+                    String.format("BluetoothGattServerManager.onNotificationSent on %s: status %d",
+                            address, status));
+            mCallback.onNotificationSent(address, status);
+        }
+
+        @Override
+        public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) {
+            /*** Not implemented yet
+             String address = device.getAddress();
+             Log.v(TAG, String.format(
+             "BluetoothGattServerManager.onExecuteWrite on %s: requestId %d, execute %b",
+             address, requestId, execute));
+             callback.onExecuteWrite(address, execute);
+             */
+        }
+
+        @Override
+        public void onServiceAdded(int status, BluetoothGattService service) {
+            UUID uuid = service.getUuid();
+            Log.v(TAG, String.format(
+                    "BluetoothGattServerManager.onServiceAdded: service %s, status %d",
+                    uuid, status));
+            mCallback.onServiceAdded(uuid, status);
+
+            for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
+                mCharacteristics.put(characteristic.getUuid(), characteristic);
+            }
+        }
+
+        @Override
+        public void onMtuChanged(BluetoothDevice device, int mtu) {
+            Log.v(TAG, String.format("onMtuChanged(mtu: %s)", mtu));
+            mCallback.onMtuChanged(device.getAddress(), mtu);
+        }
+    };
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommAcceptor.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommAcceptor.java
new file mode 100644
index 0000000..5204c2a
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommAcceptor.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironmentInternal;
+import com.android.libraries.testing.deviceshadower.helpers.utils.IOUtils;
+
+import java.io.IOException;
+import java.util.Queue;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Helper class to operate a device with basic functionality to accept BluetoothRfcommConnection.
+ *
+ * <p>
+ * Usage: // Create a virtual device to accept incoming connection. BluetoothRfcommAcceptor acceptor
+ * = new BluetoothRfcommAcceptor(address, uuid, callback); // Start accepting incoming connection,
+ * with given uuid. acceptor.start(); // Connector needs to wait till acceptor started to make sure
+ * there is a server socket created. acceptor.waitTillServerSocketStarted();
+ *
+ * // Connector can initiate connection.
+ *
+ * // A blocking call to wait for connection. acceptor.waitTillConnected();
+ *
+ * // Acceptor sends a message acceptor.send("Hello".getBytes());
+ *
+ * // Cancel acceptor to release all blocking calls. acceptor.cancel();
+ */
+public class BluetoothRfcommAcceptor {
+
+    private static final String TAG = "BluetoothRfcommAcceptor";
+
+    /**
+     * Identifiers to control Bluetooth operation.
+     */
+    public static final int PRE_START = 4;
+    public static final int PRE_ACCEPT = 1;
+    public static final int PRE_WRITE = 3;
+    public static final int PRE_READ = 2;
+
+    private final String mAddress;
+    private final UUID mUuid;
+    private BluetoothSocket mSocket;
+    private BluetoothServerSocket mServerSocket;
+
+    private final AtomicBoolean mCancelled;
+    private final Callback mCallback;
+    private final CountDownLatch mStartLatch = new CountDownLatch(1);
+    private final CountDownLatch mConnectLatch = new CountDownLatch(1);
+    private final Queue<CountDownLatch> mReadLatches = new ConcurrentLinkedQueue<>();
+
+    /**
+     * Callback of BluetoothRfcommAcceptor.
+     */
+    public interface Callback {
+
+        void onSocketAccepted(BluetoothSocket socket);
+
+        void onDataReceived(byte[] data);
+
+        void onDataWritten(byte[] data);
+
+        void onError(Exception exception);
+    }
+
+    public BluetoothRfcommAcceptor(String address, UUID uuid, Callback callback) {
+        this.mAddress = address;
+        this.mUuid = uuid;
+        this.mCallback = callback;
+        this.mCancelled = new AtomicBoolean(false);
+        DeviceShadowEnvironment.addDevice(address).bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+    }
+
+    /**
+     * Start bluetooth server socket, accept incoming connection, and receive incoming data once
+     * connected.
+     */
+    public Future<Void> start() {
+        return DeviceShadowEnvironment.run(mAddress, mCode);
+    }
+
+    /**
+     * Blocking call to wait bluetooth server socket started.
+     */
+    public void waitTillServerSocketStarted() {
+        try {
+            mStartLatch.await();
+        } catch (InterruptedException e) {
+            Log.w(TAG, mAddress + " fail to wait till started: ", e);
+        }
+    }
+
+    public void waitTillConnected() {
+        try {
+            mConnectLatch.await();
+        } catch (InterruptedException e) {
+            Log.w(TAG, mAddress + " fail to wait till started: ", e);
+        }
+    }
+
+    public void waitTillDataReceived() {
+        try {
+            if (mReadLatches.size() > 0) {
+                mReadLatches.poll().await();
+            }
+        } catch (InterruptedException e) {
+            // no-op
+        }
+    }
+
+    /**
+     * Stop receiving data by closing socket.
+     */
+    public Future<Void> cancel() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mCancelled.set(true);
+                try {
+                    mSocket.close();
+                } catch (IOException e) {
+                    Log.w(TAG, mAddress + " fail to close server socket", e);
+                }
+            }
+        });
+    }
+
+    /**
+     * Send data to connected device.
+     */
+    public Future<Void> send(final byte[] data) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                if (mSocket != null) {
+                    try {
+                        DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_WRITE);
+                        IOUtils.write(mSocket.getOutputStream(), data);
+                        Log.d(TAG, mAddress + " write: " + new String(data));
+                        mCallback.onDataWritten(data);
+                    } catch (IOException e) {
+                        Log.w(TAG, mAddress + " fail to write: ", e);
+                        mCallback.onError(new IOException("Fail to write", e));
+                    }
+                }
+            }
+        });
+    }
+
+    private Runnable mCode = new Runnable() {
+        @Override
+        public void run() {
+            try {
+                DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_START);
+                mServerSocket = BluetoothAdapter.getDefaultAdapter()
+                        .listenUsingInsecureRfcommWithServiceRecord("AA", mUuid);
+            } catch (IOException e) {
+                Log.w(TAG, mAddress + " fail to start server socket: ", e);
+                mCallback.onError(new IOException("Fail to start server socket", e));
+                return;
+            } finally {
+                mStartLatch.countDown();
+            }
+
+            try {
+                DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_ACCEPT);
+                mSocket = mServerSocket.accept();
+                Log.d(TAG, mAddress + " accept: " + mSocket.getRemoteDevice().getAddress());
+                mCallback.onSocketAccepted(mSocket);
+                mServerSocket.close();
+            } catch (IOException e) {
+                Log.w(TAG, mAddress + " fail to connect: ", e);
+                mCallback.onError(new IOException("Fail to connect", e));
+                return;
+            } finally {
+                mConnectLatch.countDown();
+            }
+
+            do {
+                try {
+                    CountDownLatch latch = new CountDownLatch(1);
+                    mReadLatches.add(latch);
+                    DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_READ);
+                    byte[] data = IOUtils.read(mSocket.getInputStream());
+                    Log.d(TAG, mAddress + " read: " + new String(data));
+                    mCallback.onDataReceived(data);
+                    latch.countDown();
+                } catch (IOException e) {
+                    Log.w(TAG, mAddress + " fail to read: ", e);
+                    mCallback.onError(new IOException("Fail to read", e));
+                    return;
+                }
+            } while (!mCancelled.get());
+
+            Log.d(TAG, mAddress + " stop receiving");
+        }
+    };
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommConnector.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommConnector.java
new file mode 100644
index 0000000..e386d59
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommConnector.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironmentInternal;
+import com.android.libraries.testing.deviceshadower.helpers.utils.IOUtils;
+
+import java.io.IOException;
+import java.util.Queue;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Helper class to operate a device with basic functionality to accept BluetoothRfcommConnection.
+ *
+ * <p>
+ * Usage: // Create a virtual device to initiate connection. BluetoothRfcommConnector connector =
+ * new BluetoothRfcommConnector(address, callback); // Start connection to a remote address with
+ * given uuid. connector.start(remoteAddress, remoteUuid);
+ *
+ * // A blocking call to wait for connection. connector.waitTillConnected();
+ *
+ * // Connector sends a message connector.send("Hello".getBytes());
+ *
+ * // Cancel connector to release all blocking calls. connector.cancel();
+ */
+public class BluetoothRfcommConnector {
+
+    private static final String TAG = "BluetoothRfcommConnector";
+
+    /**
+     * Identifiers to control Bluetooth operation.
+     */
+    public static final int PRE_CONNECT = 1;
+    public static final int PRE_READ = 2;
+    public static final int PRE_WRITE = 3;
+
+    private final String mAddress;
+    private String mRemoteAddress = null;
+    private final UUID mRemoteUuid;
+    private BluetoothSocket mSocket;
+
+    private final Callback mCallback;
+    private final AtomicBoolean mCancelled;
+    private final CountDownLatch mConnectLatch = new CountDownLatch(1);
+    private final Queue<CountDownLatch> mReadLatches = new ConcurrentLinkedQueue<>();
+
+    /**
+     * Callback of BluetoothRfcommConnector.
+     */
+    public interface Callback {
+
+        void onConnected(BluetoothSocket socket);
+
+        void onDataReceived(byte[] data);
+
+        void onDataWritten(byte[] data);
+
+        void onError(Exception exception);
+    }
+
+    public BluetoothRfcommConnector(String address, UUID uuid, Callback callback) {
+        this.mAddress = address;
+        this.mRemoteUuid = uuid;
+        this.mCallback = callback;
+        this.mCancelled = new AtomicBoolean(false);
+        DeviceShadowEnvironment.addDevice(address).bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+    }
+
+    /**
+     * Start connection to a remote address, and receive data once connected.
+     */
+    public Future<Void> start(String remoteAddress) {
+        this.mRemoteAddress = remoteAddress;
+        return DeviceShadowEnvironment.run(mAddress, mCode);
+    }
+
+    /**
+     * Stop receiving data.
+     */
+    public Future<Void> cancel() {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mCancelled.set(true);
+                try {
+                    mSocket.close();
+                } catch (IOException e) {
+                    Log.w(TAG, mAddress + " fail to close socket", e);
+                }
+            }
+        });
+    }
+
+    public void waitTillConnected() {
+        try {
+            mConnectLatch.await();
+        } catch (InterruptedException e) {
+            Log.w(TAG, mAddress + " fail to wait till started: ", e);
+        }
+    }
+
+    public void waitTillDataReceived() {
+        try {
+            if (mReadLatches.size() > 0) {
+                mReadLatches.poll().await();
+            }
+        } catch (InterruptedException e) {
+            // no-op.
+        }
+    }
+
+    /**
+     * Send data to conneceted device.
+     */
+    public Future<Void> send(final byte[] data) {
+        return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                if (mSocket != null) {
+                    try {
+                        DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_WRITE);
+                        IOUtils.write(mSocket.getOutputStream(), data);
+                        Log.d(TAG, mAddress + " write: " + new String(data));
+                        mCallback.onDataWritten(data);
+                    } catch (IOException e) {
+                        Log.w(TAG, mAddress + " fail to write: ", e);
+                        mCallback.onError(new IOException("Fail to write", e));
+                    }
+                }
+            }
+        });
+    }
+
+    private Runnable mCode = new Runnable() {
+        @Override
+        public void run() {
+            try {
+                DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_CONNECT);
+                mSocket = BluetoothAdapter.getDefaultAdapter()
+                        .getRemoteDevice(mRemoteAddress)
+                        .createInsecureRfcommSocketToServiceRecord(mRemoteUuid);
+                mSocket.connect();
+                Log.d(TAG, mAddress + " accept: " + mSocket.getRemoteDevice().getAddress());
+                mCallback.onConnected(mSocket);
+            } catch (IOException e) {
+                Log.w(TAG, mAddress + " fail to connect: ", e);
+                mCallback.onError(new IOException("Fail to connect", e));
+            } finally {
+                mConnectLatch.countDown();
+            }
+
+            try {
+                do {
+                    CountDownLatch latch = new CountDownLatch(1);
+                    mReadLatches.add(latch);
+                    DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_READ);
+                    byte[] data = IOUtils.read(mSocket.getInputStream());
+                    Log.d(TAG, mAddress + " read: " + new String(data));
+                    mCallback.onDataReceived(data);
+                    latch.countDown();
+                } while (!mCancelled.get());
+            } catch (IOException e) {
+                Log.w(TAG, mAddress + " fail to read: ", e);
+                mCallback.onError(new IOException("Fail to read", e));
+            }
+            Log.d(TAG, mAddress + " stop receiving");
+        }
+    };
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcActivity.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcActivity.java
new file mode 100644
index 0000000..8ae4435
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcActivity.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.nfc;
+
+import android.app.Activity;
+
+/**
+ * Activity that triggers or receives NFC events.
+ */
+public class NfcActivity extends Activity {
+
+    private NfcReceiver.Callback mCallback;
+
+    public void setCallback(NfcReceiver.Callback callback) {
+        this.mCallback = callback;
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        NfcReceiver.processIntent(mCallback, getIntent());
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcReceiver.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcReceiver.java
new file mode 100644
index 0000000..b85a124
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcReceiver.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.nfc;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.nfc.NdefMessage;
+import android.nfc.NfcAdapter;
+import android.os.Parcelable;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helper class to receive NFC events.
+ */
+public class NfcReceiver {
+
+    private static final String TAG = "NfcReceiver";
+
+    /**
+     * Callback to receive message.
+     */
+    public interface Callback {
+
+        void onReceive(String message);
+    }
+
+    private final String mAddress;
+    private final Activity mActivity;
+    private CountDownLatch mReceiveLatch;
+
+    private final BroadcastReceiver mReceiver;
+    private final IntentFilter mFilter;
+
+    public NfcReceiver(String address, Activity activity, final Callback callback) {
+        this(address, activity, new BroadcastReceiver() {
+            @Override
+            public void onReceive(Context context, Intent intent) {
+                if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(intent.getAction())) {
+                    processIntent(callback, intent);
+                }
+            }
+        });
+        DeviceShadowEnvironment.addDevice(address);
+    }
+
+    public NfcReceiver(
+            final String address, Activity activity, final BroadcastReceiver clientReceiver) {
+        this.mAddress = address;
+        this.mActivity = activity;
+
+        this.mFilter = new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED);
+        this.mReceiver = new BroadcastReceiver() {
+            @Override
+            public void onReceive(Context context, Intent intent) {
+                Log.v(TAG, "Receive broadcast on device " + address);
+                clientReceiver.onReceive(context, intent);
+                mReceiveLatch.countDown();
+            }
+        };
+        DeviceShadowEnvironment.addDevice(address);
+    }
+
+    public void startReceive() throws InterruptedException, ExecutionException {
+        mReceiveLatch = new CountDownLatch(1);
+
+        DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mActivity.getApplication().registerReceiver(mReceiver, mFilter);
+            }
+        }).get();
+    }
+
+    public void waitUntilReceive(long timeoutMillis) throws InterruptedException {
+        mReceiveLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
+    }
+
+    public void stopReceive() throws InterruptedException, ExecutionException {
+        DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                mActivity.getApplication().unregisterReceiver(mReceiver);
+            }
+        }).get();
+    }
+
+    static void processIntent(Callback callback, Intent intent) {
+        Parcelable[] rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES);
+        if (rawMsgs != null && rawMsgs.length > 0) {
+            // only one message sent during the beam
+            NdefMessage msg = (NdefMessage) rawMsgs[0];
+            if (callback != null) {
+                callback.onReceive(new String(msg.getRecords()[0].getPayload()));
+            }
+        }
+    }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcSender.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcSender.java
new file mode 100644
index 0000000..dbbb5fa
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcSender.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.nfc;
+
+import android.app.Activity;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.nfc.NfcAdapter.CreateNdefMessageCallback;
+import android.nfc.NfcAdapter.OnNdefPushCompleteCallback;
+import android.nfc.NfcEvent;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Helper class to send NFC events.
+ */
+public class NfcSender {
+
+    private static final String NFC_PACKAGE = "DS_PKG";
+    private static final String NFC_TAG = "DS_TAG";
+
+    /**
+     * Callback to update sender status.
+     */
+    public interface Callback {
+
+        void onSend(String message);
+    }
+
+    private final String mAddress;
+    private final Activity mActivity;
+    private final Callback mCallback;
+    private final SenderCallback mSenderCallback;
+    private String mSessage;
+
+    public NfcSender(String address, Activity activity, Callback callback) {
+        this.mCallback = callback;
+        this.mAddress = address;
+        this.mActivity = activity;
+        DeviceShadowEnvironment.addDevice(address);
+        this.mSenderCallback = new SenderCallback();
+    }
+
+    public void startSend(String message) throws InterruptedException, ExecutionException {
+        this.mSessage = message;
+        DeviceShadowEnvironment.run(mAddress, new Runnable() {
+            @Override
+            public void run() {
+                NfcAdapter nfcAdapter = NfcAdapter.getDefaultAdapter(mActivity);
+                nfcAdapter.setNdefPushMessageCallback(mSenderCallback, mActivity);
+                nfcAdapter.setOnNdefPushCompleteCallback(mSenderCallback, mActivity);
+            }
+        }).get();
+    }
+
+    class SenderCallback implements CreateNdefMessageCallback, OnNdefPushCompleteCallback {
+
+        @Override
+        public NdefMessage createNdefMessage(NfcEvent event) {
+            NdefMessage msg = new NdefMessage(new NdefRecord[]{
+                    NdefRecord.createExternal(NFC_PACKAGE, NFC_TAG, mSessage.getBytes())
+            });
+            return msg;
+        }
+
+        @Override
+        public void onNdefPushComplete(NfcEvent event) {
+            mCallback.onSend(mSessage);
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/utils/IOUtils.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/utils/IOUtils.java
new file mode 100644
index 0000000..d89754b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/utils/IOUtils.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.helpers.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * Utils for IO methods.
+ */
+public class IOUtils {
+
+    /**
+     * Write num of bytes to be sent and payload through OutputStream.
+     */
+    public static void write(OutputStream os, byte[] data) throws IOException {
+        ByteBuffer buffer = ByteBuffer.allocate(4 + data.length).putInt(data.length).put(data);
+        os.write(buffer.array());
+    }
+
+    /**
+     * Read num of bytes to be read, and payload through InputStream.
+     *
+     * @return payload received.
+     */
+    public static byte[] read(InputStream is) throws IOException {
+        byte[] size = new byte[4];
+        is.read(size, 0, 4 /* bytes of int type */);
+
+        byte[] data = new byte[ByteBuffer.wrap(size).getInt()];
+        is.read(data);
+        return data;
+    }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowEnvironmentImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowEnvironmentImpl.java
new file mode 100644
index 0000000..6a06ce4
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowEnvironmentImpl.java
@@ -0,0 +1,353 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal;
+
+import android.content.ContentProvider;
+import android.os.Looper;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.Enums.Distance;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.NamedRunnable;
+import com.android.libraries.testing.deviceshadower.internal.common.Scheduler;
+import com.android.libraries.testing.deviceshadower.internal.nfc.NfcletImpl;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsContentProvider;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsletImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.collect.ImmutableList;
+
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Proxy to manage internal data models, and help shadows to exchange data.
+ */
+public class DeviceShadowEnvironmentImpl {
+
+    private static final Logger LOGGER = Logger.create("DeviceShadowEnvironmentImpl");
+    private static final long SCHEDULER_WAIT_TIMEOUT_MILLIS = 5000L;
+
+    // ThreadLocal to store local address for each device.
+    private static InheritableThreadLocal<DeviceletImpl> sLocalDeviceletImpl =
+            new InheritableThreadLocal<>();
+
+    // Devicelets contains all registered devicelet to simulate a device.
+    private static final Map<String, DeviceletImpl> DEVICELETS = new ConcurrentHashMap<>();
+
+    @VisibleForTesting
+    static final Map<String, ExecutorService> EXECUTORS = new ConcurrentHashMap<>();
+
+    private static final List<DeviceShadowException> INTERNAL_EXCEPTIONS =
+            Collections.synchronizedList(new ArrayList<DeviceShadowException>());
+
+    private static final ContentProvider smsContentProvider = new SmsContentProvider();
+
+    public static DeviceletImpl getDeviceletImpl(String address) {
+        return DEVICELETS.get(address);
+    }
+
+    public static void checkInternalExceptions() {
+        if (INTERNAL_EXCEPTIONS.size() > 0) {
+            for (DeviceShadowException exception : INTERNAL_EXCEPTIONS) {
+                LOGGER.e("Internal exception", exception);
+            }
+            INTERNAL_EXCEPTIONS.clear();
+            throw new RuntimeException("DeviceShadower has internal exceptions");
+        }
+    }
+
+    public static void reset() {
+        // reset local devicelet for single device testing
+        sLocalDeviceletImpl.remove();
+        DEVICELETS.clear();
+        BlueletImpl.reset();
+        INTERNAL_EXCEPTIONS.clear();
+    }
+
+    public static boolean await(long timeoutMillis) {
+        boolean schedulerDone = false;
+        try {
+            schedulerDone = Scheduler.await(timeoutMillis);
+        } catch (InterruptedException e) {
+            // no-op.
+        } finally {
+            if (!schedulerDone) {
+                catchInternalException(new DeviceShadowException("Scheduler not complete"));
+                for (DeviceletImpl devicelet : DEVICELETS.values()) {
+                    LOGGER.e(
+                            String.format(
+                                    "Device %s\n\tUI: %s\n\tService: %s",
+                                    devicelet.getAddress(),
+                                    devicelet.getUiScheduler(),
+                                    devicelet.getServiceScheduler()));
+                }
+                Scheduler.clear();
+            }
+        }
+        for (ExecutorService executor : EXECUTORS.values()) {
+            executor.shutdownNow();
+        }
+        boolean terminateSuccess = true;
+        for (ExecutorService executor : EXECUTORS.values()) {
+            try {
+                executor.awaitTermination(timeoutMillis, TimeUnit.MILLISECONDS);
+            } catch (InterruptedException e) {
+                terminateSuccess = false;
+            }
+            if (!executor.isTerminated()) {
+                LOGGER.e("Failed to terminate executor.");
+                terminateSuccess = false;
+            }
+        }
+        EXECUTORS.clear();
+        return schedulerDone && terminateSuccess;
+    }
+
+    public static boolean hasLocalDeviceletImpl() {
+        return sLocalDeviceletImpl.get() != null;
+    }
+
+    public static DeviceletImpl getLocalDeviceletImpl() {
+        return sLocalDeviceletImpl.get();
+    }
+
+    public static List<DeviceletImpl> getDeviceletImpls() {
+        return ImmutableList.copyOf(DEVICELETS.values());
+    }
+
+    public static BlueletImpl getLocalBlueletImpl() {
+        return sLocalDeviceletImpl.get().blueletImpl();
+    }
+
+    public static BlueletImpl getBlueletImpl(String address) {
+        DeviceletImpl devicelet = getDeviceletImpl(address);
+        return devicelet == null ? null : devicelet.blueletImpl();
+    }
+
+    public static NfcletImpl getLocalNfcletImpl() {
+        return sLocalDeviceletImpl.get().nfcletImpl();
+    }
+
+    public static NfcletImpl getNfcletImpl(String address) {
+        DeviceletImpl devicelet = getDeviceletImpl(address);
+        return devicelet == null ? null : devicelet.nfcletImpl();
+    }
+
+    public static SmsletImpl getLocalSmsletImpl() {
+        return sLocalDeviceletImpl.get().smsletImpl();
+    }
+
+    public static ContentProvider getSmsContentProvider() {
+        return smsContentProvider;
+    }
+
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public static DeviceletImpl addDevice(String address) {
+        EXECUTORS.put(address, Executors.newCachedThreadPool());
+
+        // DeviceShadower keeps track of the "local" device based on the current thread. It uses an
+        // InheritableThreadLocal, so threads created by the current thread also get the same
+        // thread-local value. Add the device on its own thread, to set the thread local for that
+        // thread and its children.
+        try {
+            EXECUTORS
+                    .get(address)
+                    .submit(
+                            () -> {
+                                DeviceletImpl devicelet = new DeviceletImpl(address);
+                                DEVICELETS.put(address, devicelet);
+                                setLocalDevice(address);
+                                // Ensure these threads are actually created, by posting one empty
+                                // runnable.
+                                devicelet.getServiceScheduler()
+                                        .post(NamedRunnable.create("Init", () -> {
+                                        }));
+                                devicelet.getUiScheduler().post(NamedRunnable.create("Init", () -> {
+                                }));
+                            })
+                    .get();
+        } catch (InterruptedException | ExecutionException e) {
+            throw new IllegalStateException(e);
+        }
+
+        return DEVICELETS.get(address);
+    }
+
+    public static void removeDevice(String address) {
+        DEVICELETS.remove(address);
+        EXECUTORS.remove(address);
+    }
+
+    public static void setInterruptibleBluetooth(int identifier) {
+        getLocalBlueletImpl().setInterruptible(identifier);
+    }
+
+    public static void interruptBluetooth(String address, int identifier) {
+        getBlueletImpl(address).interrupt(identifier);
+    }
+
+    public static void setDistance(String address1, String address2, final Distance distance) {
+        final DeviceletImpl device1 = getDeviceletImpl(address1);
+        final DeviceletImpl device2 = getDeviceletImpl(address2);
+
+        Future<Void> result1 = null;
+        Future<Void> result2 = null;
+        if (device1.updateDistance(address2, distance)) {
+            result1 =
+                    run(
+                            address1,
+                            () -> {
+                                device1.onDistanceChange(device2, distance);
+                                return null;
+                            });
+        }
+
+        if (device2.updateDistance(address1, distance)) {
+            result2 =
+                    run(
+                            address2,
+                            () -> {
+                                device2.onDistanceChange(device1, distance);
+                                return null;
+                            });
+        }
+
+        try {
+            if (result1 != null) {
+                result1.get();
+            }
+            if (result2 != null) {
+                result2.get();
+            }
+        } catch (InterruptedException | ExecutionException e) {
+            catchInternalException(new DeviceShadowException(e));
+        }
+    }
+
+    /**
+     * Set local Bluelet for current thread.
+     *
+     * <p>This can be used to convert current running thread to hold a bluelet object, so that unit
+     * test does not have to call BluetoothEnvironment.run() to run code.
+     */
+    @VisibleForTesting
+    public static void setLocalDevice(String address) {
+        DeviceletImpl local = DEVICELETS.get(address);
+        if (local == null) {
+            throw new RuntimeException(address + " is not initialized by BluetoothEnvironment");
+        }
+        sLocalDeviceletImpl.set(local);
+    }
+
+    public static <T> Future<T> run(final String address, final Callable<T> snippet) {
+        return EXECUTORS
+                .get(address)
+                .submit(
+                        () -> {
+                            DeviceShadowEnvironmentImpl.setLocalDevice(address);
+                            ShadowLooper mainLooper = Shadows.shadowOf(Looper.getMainLooper());
+                            try {
+                                T result = snippet.call();
+
+                                // Avoid idling the main looper in paused mode since doing so is
+                                // only allowed from the main thread.
+                                if (!mainLooper.isPaused()) {
+                                    // In Robolectric, runnable doesn't run when posting thread
+                                    // differs from looper thread, idle main looper explicitly to
+                                    // execute posted Runnables.
+                                    ShadowLooper.idleMainLooper();
+                                }
+
+                                // Wait all scheduled runnables complete.
+                                Scheduler.await(SCHEDULER_WAIT_TIMEOUT_MILLIS);
+                                return result;
+                            } catch (Exception e) {
+                                LOGGER.e("Fail to call code on device: " + address, e);
+                                if (!mainLooper.isPaused()) {
+                                    // reset() is not supported in paused mode.
+                                    mainLooper.reset();
+                                }
+                                throw new RuntimeException(e);
+                            }
+                        });
+    }
+
+    // @CanIgnoreReturnValue
+    // Return value can be ignored because {@link Scheduler} will call
+    // {@link catchInternalException} to catch exceptions, and throw when test completes.
+    public static Future<?> runOnUi(String address, NamedRunnable snippet) {
+        Scheduler scheduler = DeviceShadowEnvironmentImpl.getDeviceletImpl(address)
+                .getUiScheduler();
+        return run(scheduler, address, snippet);
+    }
+
+    // @CanIgnoreReturnValue
+    // Return value can be ignored because {@link Scheduler} will call
+    // {@link catchInternalException} to catch exceptions, and throw when test completes.
+    public static Future<?> runOnService(String address, NamedRunnable snippet) {
+        Scheduler scheduler =
+                DeviceShadowEnvironmentImpl.getDeviceletImpl(address).getServiceScheduler();
+        return run(scheduler, address, snippet);
+    }
+
+    // @CanIgnoreReturnValue
+    // Return value can be ignored because {@link Scheduler} will call
+    // {@link catchInternalException} to catch exceptions, and throw when test completes.
+    private static Future<?> run(
+            Scheduler scheduler, final String address, final NamedRunnable snippet) {
+        return scheduler.post(
+                NamedRunnable.create(
+                        snippet.toString(),
+                        () -> {
+                            DeviceShadowEnvironmentImpl.setLocalDevice(address);
+                            snippet.run();
+                        }));
+    }
+
+    public static void catchInternalException(Exception exception) {
+        INTERNAL_EXCEPTIONS.add(new DeviceShadowException(exception));
+    }
+
+    // This is used to test Device Shadower internal.
+    @VisibleForTesting
+    public static void setDeviceletForTest(String address, DeviceletImpl devicelet) {
+        DEVICELETS.put(address, devicelet);
+    }
+
+    @VisibleForTesting
+    public static void setExecutorForTest(String address) {
+        setExecutorForTest(address, Executors.newCachedThreadPool());
+    }
+
+    @VisibleForTesting
+    public static void setExecutorForTest(String address, ExecutorService executor) {
+        EXECUTORS.put(address, executor);
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowException.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowException.java
new file mode 100644
index 0000000..77d358f
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowException.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal;
+
+/**
+ * Internal exception to indicate error from DeviceShadower framework.
+ */
+public class DeviceShadowException extends Exception {
+
+    public DeviceShadowException(Throwable e) {
+        super(e);
+    }
+
+    public DeviceShadowException(String msg) {
+        super(msg);
+    }
+
+    public DeviceShadowException(String msg, Throwable e) {
+        super(msg, e);
+    }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceletImpl.java
new file mode 100644
index 0000000..9aea065
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceletImpl.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal;
+
+import com.android.libraries.testing.deviceshadower.Bluelet;
+import com.android.libraries.testing.deviceshadower.Devicelet;
+import com.android.libraries.testing.deviceshadower.Enums.Distance;
+import com.android.libraries.testing.deviceshadower.Nfclet;
+import com.android.libraries.testing.deviceshadower.Smslet;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.BroadcastManager;
+import com.android.libraries.testing.deviceshadower.internal.common.Scheduler;
+import com.android.libraries.testing.deviceshadower.internal.nfc.NfcletImpl;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsletImpl;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * DeviceletImpl is the implementation to hold different medium-let in DeviceShadowEnvironment.
+ */
+public class DeviceletImpl implements Devicelet {
+
+    private final BlueletImpl mBluelet;
+    private final NfcletImpl mNfclet;
+    private final SmsletImpl mSmslet;
+    private final BroadcastManager mBroadcastManager;
+    private final String mAddress;
+    private final Map<String, Distance> mDistanceMap = new HashMap<>();
+    private final Scheduler mServiceScheduler;
+    private final Scheduler mUiScheduler;
+
+    public DeviceletImpl(String address) {
+        this.mAddress = address;
+        this.mServiceScheduler = new Scheduler(address + "-service");
+        this.mUiScheduler = new Scheduler(address + "-main");
+        this.mBroadcastManager = new BroadcastManager(mUiScheduler);
+        this.mBluelet = new BlueletImpl(address, mBroadcastManager);
+        this.mNfclet = new NfcletImpl();
+        this.mSmslet = new SmsletImpl();
+    }
+
+    @Override
+    public Bluelet bluetooth() {
+        return mBluelet;
+    }
+
+    public BlueletImpl blueletImpl() {
+        return mBluelet;
+    }
+
+    @Override
+    public Nfclet nfc() {
+        return mNfclet;
+    }
+
+    public NfcletImpl nfcletImpl() {
+        return mNfclet;
+    }
+
+    @Override
+    public Smslet sms() {
+        return mSmslet;
+    }
+
+    public SmsletImpl smsletImpl() {
+        return mSmslet;
+    }
+
+    public BroadcastManager getBroadcastManager() {
+        return mBroadcastManager;
+    }
+
+    @Override
+    public String getAddress() {
+        return mAddress;
+    }
+
+    Scheduler getServiceScheduler() {
+        return mServiceScheduler;
+    }
+
+    Scheduler getUiScheduler() {
+        return mUiScheduler;
+    }
+
+    /**
+     * Update distance to remote device.
+     *
+     * @return true if distance updated.
+     */
+    /*package*/ boolean updateDistance(String remoteAddress, Distance distance) {
+        Distance currentDistance = mDistanceMap.get(remoteAddress);
+        if (currentDistance == null || !distance.equals(currentDistance)) {
+            mDistanceMap.put(remoteAddress, distance);
+            return true;
+        }
+        return false;
+    }
+
+    /*package*/ void onDistanceChange(DeviceletImpl remote, Distance distance) {
+        if (distance == Distance.NEAR) {
+            mNfclet.onNear(remote.mNfclet);
+        }
+    }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/AdapterDelegate.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/AdapterDelegate.java
new file mode 100644
index 0000000..b5227b7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/AdapterDelegate.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothClass.Device;
+import android.os.Build.VERSION;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.DeviceletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.NamedRunnable;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Class handling Bluetooth Adapter State change. Currently async event processing is not supported,
+ * and there is no deferred operation when adapter is in a pending state.
+ */
+class AdapterDelegate {
+
+    /**
+     * Callback for adapter
+     */
+    public interface Callback {
+
+        void onAdapterStateChange(State prevState, State newState);
+
+        void onBleStateChange(State prevState, State newState);
+
+        void onDiscoveryStarted();
+
+        void onDiscoveryFinished();
+
+        void onDeviceFound(String address, int bluetoothClass, String name);
+    }
+
+    @GuardedBy("this")
+    private State mCurrentState;
+
+    private final String mAddress;
+    private final Callback mCallback;
+    private AtomicBoolean mIsDiscovering = new AtomicBoolean(false);
+    private final AtomicInteger mScanMode = new AtomicInteger(BluetoothAdapter.SCAN_MODE_NONE);
+    private int mBluetoothClass = Device.PHONE_SMART;
+
+    AdapterDelegate(String address, Callback callback) {
+        this.mAddress = address;
+        this.mCurrentState = State.OFF;
+        this.mCallback = callback;
+    }
+
+    synchronized void processEvent(Event event) {
+        State newState = TRANSITION[mCurrentState.ordinal()][event.ordinal()];
+        if (newState == null) {
+            return;
+        }
+        State prevState = mCurrentState;
+        mCurrentState = newState;
+        handleStateChange(prevState, newState);
+    }
+
+    private void handleStateChange(State prevState, State newState) {
+        // TODO(b/200231384): fake service bind/unbind on state change
+        if (prevState.equals(newState)) {
+            return;
+        }
+        if (VERSION.SDK_INT < 23) {
+            mCallback.onAdapterStateChange(prevState, newState);
+        } else {
+            mCallback.onBleStateChange(prevState, newState);
+            if (newState.equals(State.BLE_TURNING_ON)
+                    || newState.equals(State.BLE_TURNING_OFF)
+                    || newState.equals(State.OFF)
+                    || (newState.equals(State.BLE_ON) && prevState.equals(State.BLE_TURNING_ON))) {
+                return;
+            }
+            if (newState.equals(State.BLE_ON)) {
+                newState = State.OFF;
+            } else if (prevState.equals(State.BLE_ON)) {
+                prevState = State.OFF;
+            }
+            mCallback.onAdapterStateChange(prevState, newState);
+        }
+    }
+
+    synchronized State getState() {
+        return mCurrentState;
+    }
+
+    synchronized void setState(State state) {
+        mCurrentState = state;
+    }
+
+    void setBluetoothClass(int bluetoothClass) {
+        this.mBluetoothClass = bluetoothClass;
+    }
+
+    int getBluetoothClass() {
+        return mBluetoothClass;
+    }
+
+    @SuppressWarnings("FutureReturnValueIgnored")
+    void startDiscovery() {
+        synchronized (this) {
+            if (mIsDiscovering.get()) {
+                return;
+            }
+            mIsDiscovering.set(true);
+        }
+
+        mCallback.onDiscoveryStarted();
+
+        NamedRunnable onDeviceFound =
+                NamedRunnable.create(
+                        "BluetoothAdapter.onDeviceFound",
+                        new Runnable() {
+                            @Override
+                            public void run() {
+                                List<DeviceletImpl> devices =
+                                        DeviceShadowEnvironmentImpl.getDeviceletImpls();
+                                for (DeviceletImpl devicelet : devices) {
+                                    BlueletImpl bluelet = devicelet.blueletImpl();
+                                    if (mAddress.equals(devicelet.getAddress())
+                                            || bluelet.getAdapterDelegate().mScanMode.get()
+                                                    != BluetoothAdapter
+                                            .SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
+                                        continue;
+                                    }
+                                    mCallback.onDeviceFound(
+                                            bluelet.address,
+                                            bluelet.getAdapterDelegate().mBluetoothClass,
+                                            bluelet.mName);
+                                }
+                                finishDiscovery();
+                            }
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnUi(mAddress, onDeviceFound);
+    }
+
+    void cancelDiscovery() {
+        finishDiscovery();
+    }
+
+    boolean isDiscovering() {
+        return mIsDiscovering.get();
+    }
+
+    void setScanMode(int scanMode) {
+        // TODO(b/200231384): broadcast scan mode change.
+        this.mScanMode.set(scanMode);
+    }
+
+    int getScanMode() {
+        return mScanMode.get();
+    }
+
+    private void finishDiscovery() {
+        synchronized (this) {
+            if (!mIsDiscovering.get()) {
+                return;
+            }
+            mIsDiscovering.set(false);
+        }
+        mCallback.onDiscoveryFinished();
+    }
+
+    enum State {
+        OFF(BluetoothAdapter.STATE_OFF),
+        TURNING_ON(BluetoothAdapter.STATE_TURNING_ON),
+        ON(BluetoothAdapter.STATE_ON),
+        TURNING_OFF(BluetoothAdapter.STATE_TURNING_OFF),
+        // States for API23+
+        BLE_TURNING_ON(BluetoothConstants.STATE_BLE_TURNING_ON),
+        BLE_ON(BluetoothConstants.STATE_BLE_ON),
+        BLE_TURNING_OFF(BluetoothConstants.STATE_BLE_TURNING_OFF);
+
+        private static final Map<Integer, State> LOOKUP = new HashMap<>();
+
+        static {
+            for (State state : State.values()) {
+                LOOKUP.put(state.getValue(), state);
+            }
+        }
+
+        static State lookup(int value) {
+            return LOOKUP.get(value);
+        }
+
+        private final int mValue;
+
+        State(int value) {
+            this.mValue = value;
+        }
+
+        int getValue() {
+            return mValue;
+        }
+    }
+
+    /*
+     * Represents Bluetooth events which can trigger adapter state change.
+     */
+    enum Event {
+        USER_TURN_ON,
+        USER_TURN_OFF,
+        BREDR_STARTED,
+        BREDR_STOPPED,
+        // Events for API23+
+        BLE_TURN_ON,
+        BLE_TURN_OFF,
+        BLE_STARTED,
+        BLE_STOPPED
+    }
+
+    private static final State[][] TRANSITION =
+            new State[State.values().length][Event.values().length];
+
+    static {
+        if (VERSION.SDK_INT < 23) {
+            // transition table before API23
+            TRANSITION[State.OFF.ordinal()][Event.USER_TURN_ON.ordinal()] = State.TURNING_ON;
+            TRANSITION[State.TURNING_ON.ordinal()][Event.BREDR_STARTED.ordinal()] = State.ON;
+            TRANSITION[State.ON.ordinal()][Event.USER_TURN_OFF.ordinal()] = State.TURNING_OFF;
+            TRANSITION[State.TURNING_OFF.ordinal()][Event.BREDR_STOPPED.ordinal()] = State.OFF;
+        } else {
+            // transition table starting from API23
+            TRANSITION[State.OFF.ordinal()][Event.BLE_TURN_ON.ordinal()] = State.BLE_TURNING_ON;
+            TRANSITION[State.BLE_TURNING_ON.ordinal()][Event.BLE_STARTED.ordinal()] = State.BLE_ON;
+            TRANSITION[State.BLE_ON.ordinal()][Event.USER_TURN_ON.ordinal()] = State.TURNING_ON;
+            TRANSITION[State.TURNING_ON.ordinal()][Event.BREDR_STARTED.ordinal()] = State.ON;
+            TRANSITION[State.ON.ordinal()][Event.BLE_TURN_OFF.ordinal()] = State.TURNING_OFF;
+            TRANSITION[State.TURNING_OFF.ordinal()][Event.BREDR_STOPPED.ordinal()] = State.BLE_ON;
+            TRANSITION[State.BLE_ON.ordinal()][Event.USER_TURN_OFF.ordinal()] =
+                    State.BLE_TURNING_OFF;
+            TRANSITION[State.BLE_TURNING_OFF.ordinal()][Event.BLE_STOPPED.ordinal()] = State.OFF;
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BlueletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BlueletImpl.java
new file mode 100644
index 0000000..4e534e3
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BlueletImpl.java
@@ -0,0 +1,495 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth;
+
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+
+import android.Manifest.permission;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothClass;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.IBluetoothManager;
+import android.content.AttributionSource;
+import android.content.Intent;
+import android.os.Build.VERSION;
+import android.os.ParcelUuid;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.Bluelet;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.AdapterDelegate.Event;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.AdapterDelegate.State;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+import com.android.libraries.testing.deviceshadower.internal.common.BroadcastManager;
+import com.android.libraries.testing.deviceshadower.internal.common.Interrupter;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A container class of a real-world Bluetooth device.
+ */
+public class BlueletImpl implements Bluelet {
+
+    enum PairingConfirmation {
+        UNKNOWN,
+        CONFIRMED,
+        DENIED
+    }
+
+    /**
+     * See hidden {@link #EXTRA_REASON} and reason values in {@link BluetoothDevice}.
+     */
+    static final int REASON_SUCCESS = 0;
+    /**
+     * See hidden {@link #EXTRA_REASON} and reason values in {@link BluetoothDevice}.
+     */
+    static final int UNBOND_REASON_AUTH_FAILED = 1;
+    /**
+     * See hidden {@link #EXTRA_REASON} and reason values in {@link BluetoothDevice}.
+     */
+    static final int UNBOND_REASON_AUTH_CANCELED = 3;
+
+    /**
+     * Hidden in {@link BluetoothDevice}.
+     */
+    private static final String EXTRA_REASON = "android.bluetooth.device.extra.REASON";
+
+    private static final Logger LOGGER = Logger.create("BlueletImpl");
+
+    private static final ImmutableMap<Integer, Integer> PROFILE_STATE_TO_ADAPTER_STATE =
+            ImmutableMap.<Integer, Integer>builder()
+                    .put(BluetoothProfile.STATE_CONNECTED, BluetoothAdapter.STATE_CONNECTED)
+                    .put(BluetoothProfile.STATE_CONNECTING, BluetoothAdapter.STATE_CONNECTING)
+                    .put(BluetoothProfile.STATE_DISCONNECTING, BluetoothAdapter.STATE_DISCONNECTING)
+                    .put(BluetoothProfile.STATE_DISCONNECTED, BluetoothAdapter.STATE_DISCONNECTED)
+                    .build();
+
+    public static void reset() {
+        RfcommDelegate.reset();
+    }
+
+    public final String address;
+    String mName;
+    ParcelUuid[] mProfileUuids = new ParcelUuid[0];
+    int mPhonebookAccessPermission;
+    int mMessageAccessPermission;
+    int mSimAccessPermission;
+    final BluetoothAdapter mAdapter;
+    int mPassKey;
+
+    private CreateBondOutcome mCreateBondOutcome = CreateBondOutcome.SUCCESS;
+    private int mCreateBondFailureReason;
+    private IoCapabilities mIoCapabilities = IoCapabilities.NO_INPUT_NO_OUTPUT;
+    private boolean mRefuseConnections;
+    private FetchUuidsTiming mFetchUuidsTiming = FetchUuidsTiming.AFTER_BONDING;
+    private boolean mEnableCVE20192225;
+
+    private final Interrupter mInterrupter;
+    private final AdapterDelegate mAdapterDelegate;
+    private final RfcommDelegate mRfcommDelegate;
+    private final GattDelegate mGattDelegate;
+    private final BluetoothBroadcastHandler mBluetoothBroadcastHandler;
+    private final Map<String, Integer> mRemoteAddressToBondState = new HashMap<>();
+    private final Map<String, PairingConfirmation> mRemoteAddressToPairingConfirmation =
+            new HashMap<>();
+    private final Map<Integer, Integer> mProfileTypeToConnectionState = new HashMap<>();
+    private final Set<BluetoothDevice> mBondedDevices = new HashSet<>();
+
+    public BlueletImpl(String address, BroadcastManager broadcastManager) {
+        this.address = address;
+        this.mName = address;
+        this.mAdapter = callConstructor(BluetoothAdapter.class,
+                ClassParameter.from(IBluetoothManager.class, new IBluetoothManagerImpl()),
+                ClassParameter.from(AttributionSource.class,
+                        AttributionSource.myAttributionSource()));
+        mBluetoothBroadcastHandler = new BluetoothBroadcastHandler(broadcastManager);
+        mInterrupter = new Interrupter();
+        mAdapterDelegate = new AdapterDelegate(address, mBluetoothBroadcastHandler);
+        mRfcommDelegate = new RfcommDelegate(address, mBluetoothBroadcastHandler, mInterrupter);
+        mGattDelegate = new GattDelegate(address);
+    }
+
+    @Override
+    public Bluelet setAdapterInitialState(int state) throws IllegalArgumentException {
+        LOGGER.d(String.format("Address: %s, setAdapterInitialState(%d)", address, state));
+        Preconditions.checkArgument(
+                state == BluetoothAdapter.STATE_OFF || state == BluetoothAdapter.STATE_ON,
+                "State must be BluetoothAdapter.STATE_ON or BluetoothAdapter.STATE_OFF.");
+        mAdapterDelegate.setState(State.lookup(state));
+        return this;
+    }
+
+    @Override
+    public Bluelet setBluetoothClass(int bluetoothClass) {
+        mAdapterDelegate.setBluetoothClass(bluetoothClass);
+        return this;
+    }
+
+    @Override
+    public Bluelet setScanMode(int scanMode) {
+        mAdapterDelegate.setScanMode(scanMode);
+        return this;
+    }
+
+    @Override
+    public Bluelet setProfileUuids(ParcelUuid... profileUuids) {
+        this.mProfileUuids = profileUuids;
+        return this;
+    }
+
+    @Override
+    public Bluelet setIoCapabilities(IoCapabilities ioCapabilities) {
+        this.mIoCapabilities = ioCapabilities;
+        return this;
+    }
+
+    @Override
+    public Bluelet setCreateBondOutcome(CreateBondOutcome outcome, int failureReason) {
+        mCreateBondOutcome = outcome;
+        mCreateBondFailureReason = failureReason;
+        return this;
+    }
+
+    @Override
+    public Bluelet setRefuseConnections(boolean refuse) {
+        mRefuseConnections = refuse;
+        return this;
+    }
+
+    @Override
+    public Bluelet setRefuseGattConnections(boolean refuse) {
+        getGattDelegate().setRefuseConnections(refuse);
+        return this;
+    }
+
+    @Override
+    public Bluelet setFetchUuidsTiming(FetchUuidsTiming fetchUuidsTiming) {
+        this.mFetchUuidsTiming = fetchUuidsTiming;
+        return this;
+    }
+
+    @Override
+    public Bluelet addBondedDevice(String address) {
+        this.mBondedDevices.add(mAdapter.getRemoteDevice(address));
+        return this;
+    }
+
+    @Override
+    public Bluelet enableCVE20192225(boolean value) {
+        this.mEnableCVE20192225 = value;
+        return this;
+    }
+
+    IoCapabilities getIoCapabilities() {
+        return mIoCapabilities;
+    }
+
+    CreateBondOutcome getCreateBondOutcome() {
+        return mCreateBondOutcome;
+    }
+
+    int getCreateBondFailureReason() {
+        return mCreateBondFailureReason;
+    }
+
+    public boolean getRefuseConnections() {
+        return mRefuseConnections;
+    }
+
+    public FetchUuidsTiming getFetchUuidsTiming() {
+        return mFetchUuidsTiming;
+    }
+
+    BluetoothDevice[] getBondedDevices() {
+        return mBondedDevices.toArray(new BluetoothDevice[0]);
+    }
+
+    public boolean getEnableCVE20192225() {
+        return mEnableCVE20192225;
+    }
+
+    public void enableAdapter() {
+        LOGGER.d(String.format("Address: %s, enableAdapter()", address));
+        // TODO(b/200231384): async enabling, configurable delay, failure path
+        if (VERSION.SDK_INT < 23) {
+            mAdapterDelegate.processEvent(Event.USER_TURN_ON);
+            mAdapterDelegate.processEvent(Event.BREDR_STARTED);
+        } else {
+            mAdapterDelegate.processEvent(Event.BLE_TURN_ON);
+            mAdapterDelegate.processEvent(Event.BLE_STARTED);
+            mAdapterDelegate.processEvent(Event.USER_TURN_ON);
+            mAdapterDelegate.processEvent(Event.BREDR_STARTED);
+        }
+    }
+
+    public void disableAdapter() {
+        LOGGER.d(String.format("Address: %s, disableAdapter()", address));
+        // TODO(b/200231384): async disabling, configurable delay, failure path
+        if (VERSION.SDK_INT < 23) {
+            mAdapterDelegate.processEvent(Event.USER_TURN_OFF);
+            mAdapterDelegate.processEvent(Event.BREDR_STOPPED);
+        } else {
+            mAdapterDelegate.processEvent(Event.BLE_TURN_OFF);
+            mAdapterDelegate.processEvent(Event.BREDR_STOPPED);
+            mAdapterDelegate.processEvent(Event.USER_TURN_OFF);
+            mAdapterDelegate.processEvent(Event.BLE_STOPPED);
+        }
+    }
+
+    public AdapterDelegate getAdapterDelegate() {
+        return mAdapterDelegate;
+    }
+
+    public RfcommDelegate getRfcommDelegate() {
+        return mRfcommDelegate;
+    }
+
+    public GattDelegate getGattDelegate() {
+        return mGattDelegate;
+    }
+
+    public BluetoothAdapter getAdapter() {
+        return mAdapter;
+    }
+
+    public void setInterruptible(int identifier) {
+        LOGGER.d(String.format("Address: %s, setInterruptible(%d)", address, identifier));
+        mInterrupter.setInterruptible(identifier);
+    }
+
+    public void interrupt(int identifier) {
+        LOGGER.d(String.format("Address: %s, interrupt(%d)", address, identifier));
+        mInterrupter.interrupt(identifier);
+    }
+
+    @VisibleForTesting
+    public void setAdapterState(int state) throws IllegalArgumentException {
+        State s = State.lookup(state);
+        if (s == null) {
+            throw new IllegalArgumentException();
+        }
+        mAdapterDelegate.setState(s);
+    }
+
+    public int getBondState(String remoteAddress) {
+        return mRemoteAddressToBondState.containsKey(remoteAddress)
+                ? mRemoteAddressToBondState.get(remoteAddress)
+                : BluetoothDevice.BOND_NONE;
+    }
+
+    public void setBondState(String remoteAddress, int bondState, int failureReason) {
+        Intent intent =
+                newDeviceIntent(BluetoothDevice.ACTION_BOND_STATE_CHANGED, remoteAddress)
+                        .putExtra(BluetoothDevice.EXTRA_BOND_STATE, bondState)
+                        .putExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE,
+                                getBondState(remoteAddress));
+
+        if (failureReason != REASON_SUCCESS) {
+            intent.putExtra(EXTRA_REASON, failureReason);
+        }
+
+        LOGGER.d(
+                String.format(
+                        "Address: %s, Bluetooth Bond State Change Intent: remote=%s, %s -> %s "
+                                + "(reason=%s)",
+                        address, remoteAddress, getBondState(remoteAddress), bondState,
+                        failureReason));
+        mRemoteAddressToBondState.put(remoteAddress, bondState);
+        mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(
+                intent, android.Manifest.permission.BLUETOOTH);
+    }
+
+    public void onPairingRequest(String remoteAddress, int variant, int key) {
+        Intent intent =
+                newDeviceIntent(BluetoothDevice.ACTION_PAIRING_REQUEST, remoteAddress)
+                        .putExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, variant)
+                        .putExtra(BluetoothDevice.EXTRA_PAIRING_KEY, key);
+
+        LOGGER.d(
+                String.format(
+                        "Address: %s, Bluetooth Pairing Request Intent: remote=%s, variant=%s, "
+                                + "key=%s", address, remoteAddress, variant, key));
+        mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(intent, permission.BLUETOOTH);
+    }
+
+    public PairingConfirmation getPairingConfirmation(String remoteAddress) {
+        PairingConfirmation confirmation = mRemoteAddressToPairingConfirmation.get(remoteAddress);
+        return confirmation == null ? PairingConfirmation.UNKNOWN : confirmation;
+    }
+
+    public void setPairingConfirmation(String remoteAddress, PairingConfirmation confirmation) {
+        mRemoteAddressToPairingConfirmation.put(remoteAddress, confirmation);
+    }
+
+    public void onFetchedUuids(String remoteAddress, ParcelUuid[] profileUuids) {
+        Intent intent =
+                newDeviceIntent(BluetoothDevice.ACTION_UUID, remoteAddress)
+                        .putExtra(BluetoothDevice.EXTRA_UUID, profileUuids);
+
+        LOGGER.d(
+                String.format(
+                        "Address: %s, Bluetooth Found UUIDs Intent: remoteAddress=%s, uuids=%s",
+                        address, remoteAddress, Arrays.toString(profileUuids)));
+        mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(
+                intent, android.Manifest.permission.BLUETOOTH);
+    }
+
+    private static int maxProfileState(int a, int b) {
+        // Prefer connected > connecting > disconnecting > disconnected.
+        switch (a) {
+            case BluetoothProfile.STATE_CONNECTED:
+                return a;
+            case BluetoothProfile.STATE_CONNECTING:
+                return b == BluetoothProfile.STATE_CONNECTED ? b : a;
+            case BluetoothProfile.STATE_DISCONNECTING:
+                return b == BluetoothProfile.STATE_CONNECTED
+                        || b == BluetoothProfile.STATE_CONNECTING
+                        ? b
+                        : a;
+            case BluetoothProfile.STATE_DISCONNECTED:
+            default:
+                return b;
+        }
+    }
+
+    public int getAdapterConnectionState() {
+        int maxState = BluetoothProfile.STATE_DISCONNECTED;
+        for (int state : mProfileTypeToConnectionState.values()) {
+            maxState = maxProfileState(maxState, state);
+        }
+        return PROFILE_STATE_TO_ADAPTER_STATE.get(maxState);
+    }
+
+    public int getProfileConnectionState(int profileType) {
+        return mProfileTypeToConnectionState.containsKey(profileType)
+                ? mProfileTypeToConnectionState.get(profileType)
+                : BluetoothProfile.STATE_DISCONNECTED;
+    }
+
+    public void setProfileConnectionState(int profileType, int state, String remoteAddress) {
+        int previousAdapterState = getAdapterConnectionState();
+        mProfileTypeToConnectionState.put(profileType, state);
+        int adapterState = getAdapterConnectionState();
+        if (previousAdapterState != adapterState) {
+            Intent intent =
+                    newDeviceIntent(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED, remoteAddress)
+                            .putExtra(BluetoothAdapter.EXTRA_PREVIOUS_CONNECTION_STATE,
+                                    previousAdapterState)
+                            .putExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, adapterState);
+
+            LOGGER.d(
+                    "Adapter Connection State Changed Intent: "
+                            + previousAdapterState
+                            + " -> "
+                            + adapterState);
+            mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(
+                    intent, android.Manifest.permission.BLUETOOTH);
+        }
+    }
+
+    static class BluetoothBroadcastHandler implements AdapterDelegate.Callback,
+            RfcommDelegate.Callback {
+
+        private final BroadcastManager mBroadcastManager;
+
+        BluetoothBroadcastHandler(BroadcastManager broadcastManager) {
+            this.mBroadcastManager = broadcastManager;
+        }
+
+        @Override
+        public void onAdapterStateChange(State prevState, State newState) {
+            int prev = prevState.getValue();
+            int cur = newState.getValue();
+            LOGGER.d("Bluetooth State Change Intent: " + State.lookup(prev) + " -> " + State.lookup(
+                    cur));
+            Intent intent = new Intent(BluetoothAdapter.ACTION_STATE_CHANGED);
+            intent.putExtra(BluetoothAdapter.EXTRA_PREVIOUS_STATE, prev);
+            intent.putExtra(BluetoothAdapter.EXTRA_STATE, cur);
+            mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+        }
+
+        @Override
+        public void onBleStateChange(State prevState, State newState) {
+            int prev = prevState.getValue();
+            int cur = newState.getValue();
+            LOGGER.d("BLE State Change Intent: " + State.lookup(prev) + " -> " + State.lookup(cur));
+            Intent intent = new Intent(BluetoothConstants.ACTION_BLE_STATE_CHANGED);
+            intent.putExtra(BluetoothAdapter.EXTRA_PREVIOUS_STATE, prev);
+            intent.putExtra(BluetoothAdapter.EXTRA_STATE, cur);
+            mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+        }
+
+        @Override
+        public void onConnectionStateChange(String remoteAddress, boolean isConnected) {
+            LOGGER.d("Bluetooth Connection State Change Intent, isConnected: " + isConnected);
+            Intent intent =
+                    isConnected
+                            ? newDeviceIntent(BluetoothDevice.ACTION_ACL_CONNECTED, remoteAddress)
+                            : newDeviceIntent(BluetoothDevice.ACTION_ACL_DISCONNECTED,
+                                    remoteAddress);
+            mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+        }
+
+        @Override
+        public void onDiscoveryStarted() {
+            LOGGER.d("Bluetooth discovery started.");
+            Intent intent = new Intent(BluetoothAdapter.ACTION_DISCOVERY_STARTED);
+            mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+        }
+
+        @Override
+        public void onDiscoveryFinished() {
+            LOGGER.d("Bluetooth discovery finished.");
+            Intent intent = new Intent(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
+            mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+        }
+
+        @Override
+        public void onDeviceFound(String address, int bluetoothClass, String name) {
+            LOGGER.d("Bluetooth device found, address: " + address);
+            Intent intent =
+                    newDeviceIntent(BluetoothDevice.ACTION_FOUND, address)
+                            .putExtra(
+                                    BluetoothDevice.EXTRA_CLASS,
+                                    callConstructor(
+                                            BluetoothClass.class,
+                                            ClassParameter.from(int.class, bluetoothClass)))
+                            .putExtra(BluetoothDevice.EXTRA_NAME, name);
+            // TODO(b/200231384): support rssi
+            // TODO(b/200231384): send broadcast with additional ACCESS_COARSE_LOCATION permission
+            // once broadcast permission is implemented.
+            mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+        }
+    }
+
+    private static Intent newDeviceIntent(String action, String address) {
+        return new Intent(action)
+                .putExtra(
+                        BluetoothDevice.EXTRA_DEVICE,
+                        BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address));
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BluetoothConstants.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BluetoothConstants.java
new file mode 100644
index 0000000..fa519da
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BluetoothConstants.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth;
+
+/**
+ * A class to hold Bluetooth constants.
+ */
+public class BluetoothConstants {
+
+    /*** Bluetooth Adapter State ***/
+    // Must be identical to BluetoothAdapter hidden field STATE_BLE_TURNING_ON
+    public static final int STATE_BLE_TURNING_ON = 14;
+
+    // Must be identical to BluetoothAdapter hidden field STATE_BLE_ON
+    public static final int STATE_BLE_ON = 15;
+
+    // Must be identical to BluetoothAdapter hidden field STATE_BLE_TURNING_OFF
+    public static final int STATE_BLE_TURNING_OFF = 16;
+
+    // Must be identical to BluetoothAdapter hidden field ACTION_BLE_STATE_CHANGED
+    public static final String ACTION_BLE_STATE_CHANGED =
+            "android.bluetooth.adapter.action.BLE_STATE_CHANGED";
+
+    /*** Rfcomm Socket ***/
+    // Must be identical to BluetoothSocket field TYPE_RFCOMM.
+    // The field was package-private before M.
+    public static final int TYPE_RFCOMM = 1;
+
+    public static final int SOCKET_CLOSE = -10000;
+
+    // Android Bluetooth use -1 as port when creating server socket with uuid
+    public static final int SERVER_SOCKET_CHANNEL_AUTO_ASSIGN = -1;
+
+    // Android Bluetooth use -1 as port when creating socket with a uuid
+    public static final int SOCKET_CHANNEL_CONNECT_WITH_UUID = -1;
+
+    /*** BLE Advertise/Scan ***/
+    // Must be identical to AdvertiseCallback hidden field ADVERTISE_SUCCESS.
+    public static final int ADVERTISE_SUCCESS = 0;
+
+    // Must be identical to ScanRecord field DATA_TYPE_FLAGS.
+    public static final int DATA_TYPE_FLAGS = 0x01;
+
+    // Must be identical to ScanRecord field DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE.
+    public static final int DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE = 0x07;
+
+    // Must be identical to ScanRecord field DATA_TYPE_LOCAL_NAME_COMPLETE.
+    public static final int DATA_TYPE_LOCAL_NAME_COMPLETE = 0x09;
+
+    // Must be identical to ScanRecord field DATA_TYPE_TX_POWER_LEVEL.
+    public static final int DATA_TYPE_TX_POWER_LEVEL = 0x0A;
+
+    // Must be identical to ScanRecord field DATA_TYPE_SERVICE_DATA.
+    public static final int DATA_TYPE_SERVICE_DATA = 0x16;
+
+    // Must be identical to ScanRecord field DATA_TYPE_MANUFACTURER_SPECIFIC_DATA.
+    public static final int DATA_TYPE_MANUFACTURER_SPECIFIC_DATA = 0xFF;
+
+    /**
+     * @see #DATA_TYPE_FLAGS
+     */
+    public interface Flags {
+
+        byte LE_LIMITED_DISCOVERABLE_MODE = 1;
+        byte LE_GENERAL_DISCOVERABLE_MODE = 1 << 1;
+        byte BR_EDR_NOT_SUPPORTED = 1 << 2;
+        byte SIMULTANEOUS_LE_AND_BR_EDR_CONTROLLER = 1 << 3;
+        byte SIMULTANEOUS_LE_AND_BR_EDR_HOST = 1 << 4;
+    }
+
+    /**
+     * Observed that Android sets this for {@link #DATA_TYPE_FLAGS} when a packet is connectable (on
+     * a Nexus 6P running 7.1.2).
+     */
+    public static final byte FLAGS_IN_CONNECTABLE_PACKETS =
+            Flags.BR_EDR_NOT_SUPPORTED
+                    | Flags.LE_GENERAL_DISCOVERABLE_MODE
+                    | Flags.SIMULTANEOUS_LE_AND_BR_EDR_CONTROLLER
+                    | Flags.SIMULTANEOUS_LE_AND_BR_EDR_HOST;
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/GattDelegate.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/GattDelegate.java
new file mode 100644
index 0000000..4618561
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/GattDelegate.java
@@ -0,0 +1,609 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.IBluetoothGattCallback;
+import android.bluetooth.IBluetoothGattServerCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.ParcelUuid;
+import android.os.SystemClock;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.DeviceletImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.GattHelper;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+import com.google.common.primitives.Bytes;
+
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Delegate to operate gatt operations.
+ */
+public class GattDelegate {
+
+    private static final int DEFAULT_RSSI = -50;
+    private static final Logger LOGGER = Logger.create("GattDelegate");
+
+    // chipset properties
+    // use 2 as API 21 requires multi-advertisement support to use Le Advertising.
+    private final int mMaxAdvertiseInstances = 2;
+    private final AtomicBoolean mIsOffloadedFilteringSupported = new AtomicBoolean(false);
+    private final String mAddress;
+    private final AtomicInteger mCurrentClientIf = new AtomicInteger(0);
+    private final AtomicInteger mCurrentServerIf = new AtomicInteger(0);
+    private final AtomicBoolean mCurrentConnectionState = new AtomicBoolean(false);
+    private final Map<ParcelUuid, Service> mServices = new HashMap<>();
+    private final Map<Integer, IBluetoothGattCallback> mClientCallbacks;
+    private final Map<Integer, IBluetoothGattServerCallback> mServerCallbacks;
+    private final Map<Integer, Advertiser> mAdvertisers;
+    private final Map<Integer, Scanner> mScanners;
+    @Nullable
+    private Request mLastRequest;
+    private boolean mConnectable = true;
+
+    /**
+     * The parameters of a request, e.g. readCharacteristic(). Subclass for each request.
+     *
+     * @see #getLastRequest()
+     */
+    abstract static class Request {
+
+        final int mSrvcType;
+        final int mSrvcInstId;
+        final ParcelUuid mSrvcId;
+        final int mCharInstId;
+        final ParcelUuid mCharId;
+
+        Request(int srvcType, int srvcInstId, ParcelUuid srvcId, int charInstId,
+                ParcelUuid charId) {
+            this.mSrvcType = srvcType;
+            this.mSrvcInstId = srvcInstId;
+            this.mSrvcId = srvcId;
+            this.mCharInstId = charInstId;
+            this.mCharId = charId;
+        }
+    }
+
+    /**
+     * Corresponds to {@link android.bluetooth.IBluetoothGatt#readCharacteristic}.
+     */
+    static class ReadCharacteristicRequest extends Request {
+
+        ReadCharacteristicRequest(
+                int srvcType, int srvcInstId, ParcelUuid srvcId, int charInstId,
+                ParcelUuid charId) {
+            super(srvcType, srvcInstId, srvcId, charInstId, charId);
+        }
+    }
+
+    /**
+     * Corresponds to {@link android.bluetooth.IBluetoothGatt#readDescriptor}.
+     */
+    static class ReadDescriptorRequest extends Request {
+
+        final int mDescrInstId;
+        final ParcelUuid mDescrId;
+
+        ReadDescriptorRequest(
+                int srvcType,
+                int srvcInstId,
+                ParcelUuid srvcId,
+                int charInstId,
+                ParcelUuid charId,
+                int descrInstId,
+                ParcelUuid descrId) {
+            super(srvcType, srvcInstId, srvcId, charInstId, charId);
+            this.mDescrInstId = descrInstId;
+            this.mDescrId = descrId;
+        }
+    }
+
+    GattDelegate(String address) {
+        this(
+                address,
+                new HashMap<>(),
+                new HashMap<>(),
+                new ConcurrentHashMap<>(),
+                new ConcurrentHashMap<>());
+    }
+
+    @VisibleForTesting
+    GattDelegate(
+            String address,
+            Map<Integer, IBluetoothGattCallback> clientCallbacks,
+            Map<Integer, IBluetoothGattServerCallback> serverCallbacks,
+            Map<Integer, Advertiser> advertisers,
+            Map<Integer, Scanner> scanners) {
+        this.mAddress = address;
+        this.mClientCallbacks = clientCallbacks;
+        this.mServerCallbacks = serverCallbacks;
+        this.mAdvertisers = advertisers;
+        this.mScanners = scanners;
+    }
+
+    public void setRefuseConnections(boolean refuse) {
+        this.mConnectable = !refuse;
+    }
+
+    /**
+     * Used to maintain state between the request (e.g. readCharacteristic()) and sendResponse().
+     */
+    @Nullable
+    Request getLastRequest() {
+        return mLastRequest;
+    }
+
+    /**
+     * @see #getLastRequest()
+     */
+    void setLastRequest(@Nullable Request params) {
+        mLastRequest = params;
+    }
+
+    public int getClientIf() {
+        // TODO(b/200231384): support multiple client if.
+        return mCurrentClientIf.get();
+    }
+
+    public int getServerIf() {
+        // TODO(b/200231384): support multiple server if.
+        return mCurrentServerIf.get();
+    }
+
+    public IBluetoothGattServerCallback getServerCallback(int serverIf) {
+        return mServerCallbacks.get(serverIf);
+    }
+
+    public IBluetoothGattCallback getClientCallback(int clientIf) {
+        return mClientCallbacks.get(clientIf);
+    }
+
+    public int registerServer(IBluetoothGattServerCallback callback) {
+        mServerCallbacks.put(mCurrentServerIf.incrementAndGet(), callback);
+        return getServerIf();
+    }
+
+    public int registerClient(IBluetoothGattCallback callback) {
+        mClientCallbacks.put(mCurrentClientIf.incrementAndGet(), callback);
+        LOGGER.d(String.format("Client registered on %s, clientIf: %d", mAddress, getClientIf()));
+        return getClientIf();
+    }
+
+    public void unregisterClient(int clientIf) {
+        mClientCallbacks.remove(clientIf);
+        LOGGER.d(String.format("Client unregistered on %s, clientIf: %d", mAddress, clientIf));
+    }
+
+    public void unregisterServer(int serverIf) {
+        mServerCallbacks.remove(serverIf);
+    }
+
+    public int getMaxAdvertiseInstances() {
+        return mMaxAdvertiseInstances;
+    }
+
+    public boolean isOffloadedFilteringSupported() {
+        return mIsOffloadedFilteringSupported.get();
+    }
+
+    public boolean connect(String address) {
+        return mConnectable;
+    }
+
+    public boolean disconnect(String address) {
+        return true;
+    }
+
+    public void clientConnectionStateChange(
+            int state, int clientIf, boolean connected, String address) {
+        if (connected != mCurrentConnectionState.get()) {
+            mCurrentConnectionState.set(connected);
+            IBluetoothGattCallback callback = getClientCallback(clientIf);
+            if (callback != null) {
+                callback.onClientConnectionState(state, clientIf, connected, address);
+            }
+        }
+    }
+
+    public void serverConnectionStateChange(
+            int state, int serverIf, boolean connected, String address) {
+        if (connected != mCurrentConnectionState.get()) {
+            mCurrentConnectionState.set(connected);
+            IBluetoothGattServerCallback callback = getServerCallback(serverIf);
+            if (callback != null) {
+                callback.onServerConnectionState(state, serverIf, connected, address);
+            }
+        }
+    }
+
+    public Service addService(ParcelUuid uuid) {
+        Service srvc = new Service(uuid);
+        mServices.put(uuid, srvc);
+        return srvc;
+    }
+
+    public Collection<Service> getServices() {
+        return mServices.values();
+    }
+
+    public Service getService(ParcelUuid uuid) {
+        return mServices.get(uuid);
+    }
+
+    public void clientSetMtu(int clientIf, int mtu, String serverAddress) {
+        IBluetoothGattCallback callback = getClientCallback(clientIf);
+        if (callback != null && Build.VERSION.SDK_INT >= 21) {
+            callback.onConfigureMTU(serverAddress, mtu, BluetoothGatt.GATT_SUCCESS);
+        }
+    }
+
+    public void serverSetMtu(int serverIf, int mtu, String clientAddress) {
+        IBluetoothGattServerCallback callback = getServerCallback(serverIf);
+        if (callback != null && Build.VERSION.SDK_INT >= 22) {
+            callback.onMtuChanged(clientAddress, mtu);
+        }
+    }
+
+    public void startMultiAdvertising(
+            int appIf,
+            AdvertiseData advertiseData,
+            AdvertiseData scanResponse,
+            final AdvertiseSettings settings) {
+        LOGGER.d(String.format("startMultiAdvertising(%d) on %s", appIf, mAddress));
+        final Advertiser advertiser =
+                new Advertiser(
+                        appIf,
+                        mAddress,
+                        DeviceShadowEnvironmentImpl.getLocalBlueletImpl().mName,
+                        txPowerFromFlag(settings.getTxPowerLevel()),
+                        advertiseData,
+                        scanResponse,
+                        settings);
+        mAdvertisers.put(appIf, advertiser);
+        final IBluetoothGattCallback callback = mClientCallbacks.get(appIf);
+        @SuppressWarnings("unused") // go/futurereturn-lsc
+        Future<?> possiblyIgnoredError =
+                DeviceShadowEnvironmentImpl.run(
+                        mAddress,
+                        () -> {
+                            callback.onMultiAdvertiseCallback(
+                                    BluetoothConstants.ADVERTISE_SUCCESS, true /* isStart */,
+                                    settings);
+                            return null;
+                        });
+    }
+
+    /**
+     * Returns TxPower in dBm as measured at the source.
+     *
+     * <p>Note that this will vary by device and the values are only roughly accurate. The
+     * measurements were taken with a Nexus 6. Copied from the TxEddystone-UID app:
+     * {https://github.com/google/eddystone/blob/master/eddystone-uid/tools/txeddystone-uid/TxEddystone-UID/app/src/main/java/com/google/sample/txeddystone_uid/MainActivity.java}
+     */
+    private static byte txPowerFromFlag(int txPowerFlag) {
+        switch (txPowerFlag) {
+            case AdvertiseSettings.ADVERTISE_TX_POWER_HIGH:
+                return (byte) -16;
+            case AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM:
+                return (byte) -26;
+            case AdvertiseSettings.ADVERTISE_TX_POWER_LOW:
+                return (byte) -35;
+            case AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW:
+                return (byte) -59;
+            default:
+                throw new IllegalStateException("Unknown TxPower level=" + txPowerFlag);
+        }
+    }
+
+    public void stopMultiAdvertising(int appIf) {
+        LOGGER.d(String.format("stopAdvertising(%d) on %s", appIf, mAddress));
+        Advertiser advertiser = mAdvertisers.get(appIf);
+        if (advertiser == null) {
+            LOGGER.d(String.format("Advertising already stopped on %s, clientIf: %d", mAddress,
+                    appIf));
+            return;
+        }
+        mAdvertisers.remove(appIf);
+        final IBluetoothGattCallback callback = mClientCallbacks.get(appIf);
+        @SuppressWarnings("unused") // go/futurereturn-lsc
+        Future<?> possiblyIgnoredError =
+                DeviceShadowEnvironmentImpl.run(
+                        mAddress,
+                        () -> {
+                            callback.onMultiAdvertiseCallback(
+                                    BluetoothConstants.ADVERTISE_SUCCESS, false /* isStart */,
+                                    null /* setting */);
+                            return null;
+                        });
+    }
+
+    public void startScan(final int appIf, ScanSettings settings, List<ScanFilter> filters) {
+        LOGGER.d(String.format("startScan(%d) on %s", appIf, mAddress));
+        if (filters == null) {
+            filters = new ArrayList<>();
+        }
+        final Scanner scanner = new Scanner(appIf, settings, filters);
+        mScanners.put(appIf, scanner);
+        @SuppressWarnings("unused") // go/futurereturn-lsc
+        Future<?> possiblyIgnoredError =
+                DeviceShadowEnvironmentImpl.run(
+                        mAddress,
+                        () -> {
+                            try {
+                                scan(scanner);
+                            } catch (InterruptedException e) {
+                                LOGGER.e(
+                                        String.format("Failed to scan on %s, clientIf: %d.",
+                                                mAddress, scanner.mClientIf),
+                                        e);
+                            }
+                            return null;
+                        });
+    }
+
+    // TODO(b/200231384): support periodic scan with interval and scan window.
+    private void scan(Scanner scanner) throws InterruptedException {
+        // fetch existing advertisements
+        List<DeviceletImpl> devicelets = DeviceShadowEnvironmentImpl.getDeviceletImpls();
+        for (DeviceletImpl devicelet : devicelets) {
+            BlueletImpl bluelet = devicelet.blueletImpl();
+            if (bluelet.address.equals(mAddress)) {
+                continue;
+            }
+            for (Advertiser advertiser : bluelet.getGattDelegate().mAdvertisers.values()) {
+                if (VERSION.SDK_INT < 21) {
+                    throw new UnsupportedOperationException(
+                            String.format("API %d is not supported.", VERSION.SDK_INT));
+                }
+
+                byte[] advertiseData =
+                        GattHelper.convertAdvertiseData(
+                                advertiser.mAdvertiseData,
+                                advertiser.mTxPowerLevel,
+                                advertiser.mName,
+                                advertiser.mSettings.isConnectable());
+                byte[] scanResponse =
+                        GattHelper.convertAdvertiseData(
+                                advertiser.mScanResponse,
+                                advertiser.mTxPowerLevel,
+                                advertiser.mName,
+                                advertiser.mSettings.isConnectable());
+
+                ScanRecord scanRecord =
+                        ReflectionHelpers.callStaticMethod(
+                                ScanRecord.class,
+                                "parseFromBytes",
+                                ClassParameter.from(byte[].class,
+                                        Bytes.concat(advertiseData, scanResponse)));
+                ScanResult scanResult =
+                        new ScanResult(
+                                BluetoothAdapter.getDefaultAdapter()
+                                        .getRemoteDevice(advertiser.mAddress),
+                                scanRecord,
+                                DEFAULT_RSSI,
+                                SystemClock.elapsedRealtimeNanos());
+
+                if (!matchFilters(scanResult, scanner.mFilters)) {
+                    continue;
+                }
+
+                IBluetoothGattCallback callback = mClientCallbacks.get(scanner.mClientIf);
+                if (callback == null) {
+                    LOGGER.e(
+                            String.format("Callback is null on %s, clientIf: %d", mAddress,
+                                    scanner.mClientIf));
+                    return;
+                }
+                callback.onScanResult(scanResult);
+            }
+        }
+    }
+
+    private boolean matchFilters(ScanResult scanResult, List<ScanFilter> filters) {
+        for (ScanFilter filter : filters) {
+            if (!filter.matches(scanResult)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    public void stopScan(int appIf) {
+        LOGGER.d(String.format("stopScan(%d) on %s", appIf, mAddress));
+        Scanner scanner = mScanners.get(appIf);
+        if (scanner == null) {
+            LOGGER.d(
+                    String.format("Scanning already stopped on %s, clientIf: %d", mAddress, appIf));
+            return;
+        }
+        mScanners.remove(appIf);
+    }
+
+    static class Service {
+
+        private Map<ParcelUuid, Characteristic> mCharacteristics = new HashMap<>();
+        private ParcelUuid mUuid;
+
+        Service(ParcelUuid uuid) {
+            this.mUuid = uuid;
+        }
+
+        Characteristic getCharacteristic(ParcelUuid uuid) {
+            return mCharacteristics.get(uuid);
+        }
+
+        Characteristic addCharacteristic(ParcelUuid uuid, int properties, int permissions) {
+            Characteristic ch = new Characteristic(uuid, properties, permissions);
+            mCharacteristics.put(uuid, ch);
+            return ch;
+        }
+
+        Collection<Characteristic> getCharacteristics() {
+            return mCharacteristics.values();
+        }
+
+        ParcelUuid getUuid() {
+            return this.mUuid;
+        }
+    }
+
+    static class Characteristic {
+
+        private int mProperties;
+        private ParcelUuid mUuid;
+        private Map<ParcelUuid, Descriptor> mDescriptors = new HashMap<>();
+        private Set<String> mNotifyClients = new HashSet<>();
+        private byte[] mValue;
+
+        Characteristic(ParcelUuid uuid, int properties, int permissions) {
+            this.mProperties = properties;
+            this.mUuid = uuid;
+        }
+
+        Descriptor getDescriptor(ParcelUuid uuid) {
+            return mDescriptors.get(uuid);
+        }
+
+        Descriptor addDescriptor(ParcelUuid uuid, int permissions) {
+            Descriptor desc = new Descriptor(uuid, permissions);
+            mDescriptors.put(uuid, desc);
+            return desc;
+        }
+
+        Collection<Descriptor> getDescriptors() {
+            return mDescriptors.values();
+        }
+
+        void setValue(byte[] value) {
+            this.mValue = value;
+        }
+
+        byte[] getValue() {
+            return mValue;
+        }
+
+        ParcelUuid getUuid() {
+            return mUuid;
+        }
+
+        int getProperties() {
+            return mProperties;
+        }
+
+        void registerNotification(String client, int clientIf) {
+            mNotifyClients.add(client);
+        }
+
+        Set<String> getNotifyClients() {
+            return mNotifyClients;
+        }
+    }
+
+    static class Descriptor {
+
+        int mPermissions;
+        ParcelUuid mUuid;
+        byte[] mValue;
+
+        Descriptor(ParcelUuid uuid, int permissions) {
+            this.mUuid = uuid;
+            this.mPermissions = permissions;
+        }
+
+        void setValue(byte[] value) {
+            this.mValue = value;
+        }
+
+        byte[] getValue() {
+            return mValue;
+        }
+
+        ParcelUuid getUuid() {
+            return mUuid;
+        }
+    }
+
+    @VisibleForTesting
+    static class Advertiser {
+
+        final int mClientIf;
+        final String mAddress;
+        final String mName;
+        final int mTxPowerLevel;
+        final AdvertiseData mAdvertiseData;
+        @Nullable
+        final AdvertiseData mScanResponse;
+        final AdvertiseSettings mSettings;
+
+        Advertiser(
+                int clientIf,
+                String address,
+                String name,
+                int txPowerLevel,
+                AdvertiseData advertiseData,
+                AdvertiseData scanResponse,
+                AdvertiseSettings settings) {
+            this.mClientIf = clientIf;
+            this.mAddress = Preconditions.checkNotNull(address);
+            this.mName = name;
+            this.mTxPowerLevel = txPowerLevel;
+            this.mAdvertiseData = Preconditions.checkNotNull(advertiseData);
+            this.mScanResponse = scanResponse;
+            this.mSettings = Preconditions.checkNotNull(settings);
+        }
+    }
+
+    @VisibleForTesting
+    static class Scanner {
+
+        final int mClientIf;
+        final ScanSettings mSettings;
+        final List<ScanFilter> mFilters;
+
+        Scanner(int clientIf, ScanSettings settings, List<ScanFilter> filters) {
+            this.mClientIf = clientIf;
+            this.mSettings = Preconditions.checkNotNull(settings);
+            this.mFilters = Preconditions.checkNotNull(filters);
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothGattImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothGattImpl.java
new file mode 100644
index 0000000..0ac287d
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothGattImpl.java
@@ -0,0 +1,707 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.IBluetoothGatt;
+import android.bluetooth.IBluetoothGattCallback;
+import android.bluetooth.IBluetoothGattServerCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.os.ParcelUuid;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.GattDelegate.ReadCharacteristicRequest;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.GattDelegate.ReadDescriptorRequest;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.GattDelegate.Request;
+import com.android.libraries.testing.deviceshadower.internal.common.NamedRunnable;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation of IBluetoothGatt.
+ */
+public class IBluetoothGattImpl implements IBluetoothGatt {
+
+    private static final Logger LOGGER = Logger.create("IBluetoothGattImpl");
+    private GattDelegate.Service mCurrentService;
+    private GattDelegate.Characteristic mCurrentCharacteristic;
+
+    @Override
+    public void startScan(
+            int appIf,
+            boolean isServer,
+            ScanSettings settings,
+            List<ScanFilter> filters,
+            List<?> scanStorages,
+            String callingPackage) {
+        localGattDelegate().startScan(appIf, settings, filters);
+    }
+
+    @Override
+    public void startScan(
+            int appIf,
+            boolean isServer,
+            ScanSettings settings,
+            List<ScanFilter> filters,
+            List<?> scanStorages) {
+        startScan(appIf, isServer, settings, filters, scanStorages, "" /* callingPackage */);
+    }
+
+    @Override
+    public void stopScan(int appIf, boolean isServer) {
+        localGattDelegate().stopScan(appIf);
+    }
+
+    @Override
+    public void startMultiAdvertising(
+            int appIf,
+            AdvertiseData advertiseData,
+            AdvertiseData scanResponse,
+            AdvertiseSettings settings) {
+        localGattDelegate().startMultiAdvertising(appIf, advertiseData, scanResponse, settings);
+    }
+
+    @Override
+    public void stopMultiAdvertising(int appIf) {
+        localGattDelegate().stopMultiAdvertising(appIf);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void registerClient(ParcelUuid appId, final IBluetoothGattCallback callback) {
+        final int clientIf = localGattDelegate().registerClient(callback);
+        NamedRunnable onClientRegistered =
+                NamedRunnable.create(
+                        "ClientGatt.onClientRegistered=" + clientIf,
+                        () -> {
+                            callback.onClientRegistered(BluetoothGatt.GATT_SUCCESS, clientIf);
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(localAddress(), onClientRegistered);
+    }
+
+    @Override
+    public void unregisterClient(int clientIf) {
+        localGattDelegate().unregisterClient(clientIf);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void clientConnect(
+            final int clientIf, final String serverAddress, boolean isDirect, int transport) {
+        // TODO(b/200231384): implement auto connect.
+        String clientAddress = localAddress();
+        int serverIf = remoteGattDelegate(serverAddress).getServerIf();
+        boolean success = remoteGattDelegate(serverAddress).connect(clientAddress);
+        if (!success) {
+            LOGGER.i(String.format("clientConnect failed: %s connect %s", serverAddress,
+                    clientAddress));
+            return;
+        }
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                clientAddress,
+                newClientConnectionStateChangeRunnable(clientIf, true, serverAddress));
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                serverAddress,
+                newServerConnectionStateChangeRunnable(serverIf, true, clientAddress));
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void clientDisconnect(final int clientIf, final String serverAddress) {
+        final String clientAddress = localAddress();
+        remoteGattDelegate(serverAddress).disconnect(clientAddress);
+        int serverIf = remoteGattDelegate(serverAddress).getServerIf();
+
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                clientAddress,
+                newClientConnectionStateChangeRunnable(clientIf, false, serverAddress));
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                serverAddress,
+                newServerConnectionStateChangeRunnable(serverIf, false, clientAddress));
+    }
+
+    @Override
+    public void discoverServices(int clientIf, String serverAddress) {
+        final IBluetoothGattCallback callback = localGattDelegate().getClientCallback(clientIf);
+        if (callback == null) {
+            return;
+        }
+        for (GattDelegate.Service service : remoteGattDelegate(serverAddress).getServices()) {
+            callback.onGetService(serverAddress, 0 /*srvcType*/, 0 /*srvcInstId*/,
+                    service.getUuid());
+
+            for (GattDelegate.Characteristic characteristic : service.getCharacteristics()) {
+                callback.onGetCharacteristic(
+                        serverAddress,
+                        0 /*srvcType*/,
+                        0 /*srvcInstId*/,
+                        service.getUuid(),
+                        0 /*charInstId*/,
+                        characteristic.getUuid(),
+                        characteristic.getProperties());
+                for (GattDelegate.Descriptor descriptor : characteristic.getDescriptors()) {
+                    callback.onGetDescriptor(
+                            serverAddress,
+                            0 /*srvcType*/,
+                            0 /*srvcInstId*/,
+                            service.getUuid(),
+                            0 /*charInstId*/,
+                            characteristic.getUuid(),
+                            0 /*descrInstId*/,
+                            descriptor.getUuid());
+                }
+            }
+        }
+
+        callback.onSearchComplete(serverAddress, BluetoothGatt.GATT_SUCCESS);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void readCharacteristic(
+            final int clientIf,
+            final String serverAddress,
+            final int srvcType,
+            final int srvcInstId,
+            final ParcelUuid srvcId,
+            final int charInstId,
+            final ParcelUuid charId,
+            final int authReq) {
+        // TODO(b/200231384): implement authReq.
+        final String clientAddress = localAddress();
+        localGattDelegate()
+                .setLastRequest(
+                        new ReadCharacteristicRequest(srvcType, srvcInstId, srvcId, charInstId,
+                                charId));
+
+        NamedRunnable serverOnCharacteristicReadRequest =
+                NamedRunnable.create(
+                        "ServerGatt.onCharacteristicReadRequest",
+                        () -> {
+                            int serverIf = localGattDelegate().getServerIf();
+                            IBluetoothGattServerCallback callback =
+                                    localGattDelegate().getServerCallback(serverIf);
+                            if (callback != null) {
+                                callback.onCharacteristicReadRequest(
+                                        clientAddress,
+                                        0 /*transId*/,
+                                        0 /*offset*/,
+                                        false /*isLong*/,
+                                        0 /*srvcType*/,
+                                        srvcInstId,
+                                        srvcId,
+                                        charInstId,
+                                        charId);
+                            }
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnCharacteristicReadRequest);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void writeCharacteristic(
+            final int clientIf,
+            final String serverAddress,
+            final int srvcType,
+            final int srvcInstId,
+            final ParcelUuid srvcId,
+            final int charInstId,
+            final ParcelUuid charId,
+            final int writeType,
+            final int authReq,
+            final byte[] value) {
+        // TODO(b/200231384): implement write with response needed.
+        remoteGattDelegate(serverAddress).getService(srvcId).getCharacteristic(charId)
+                .setValue(value);
+        final String clientAddress = localAddress();
+
+        NamedRunnable clientOnCharacteristicWrite =
+                NamedRunnable.create(
+                        "ClientGatt.onCharacteristicWrite",
+                        () -> {
+                            IBluetoothGattCallback callback = localGattDelegate().getClientCallback(
+                                    clientIf);
+                            if (callback != null) {
+                                callback.onCharacteristicWrite(
+                                        serverAddress,
+                                        BluetoothGatt.GATT_SUCCESS,
+                                        0 /*srvcType*/,
+                                        srvcInstId,
+                                        srvcId,
+                                        charInstId,
+                                        charId);
+                            }
+                        });
+
+        NamedRunnable onCharacteristicWriteRequest =
+                NamedRunnable.create(
+                        "ServerGatt.onCharacteristicWriteRequest",
+                        () -> {
+                            int serverIf = localGattDelegate().getServerIf();
+                            IBluetoothGattServerCallback callback =
+                                    localGattDelegate().getServerCallback(serverIf);
+                            if (callback != null) {
+                                callback.onCharacteristicWriteRequest(
+                                        clientAddress,
+                                        0 /*transId*/,
+                                        0 /*offset*/,
+                                        value.length,
+                                        false /*isPrep*/,
+                                        false /*needRsp*/,
+                                        0 /*srvcType*/,
+                                        srvcInstId,
+                                        srvcId,
+                                        charInstId,
+                                        charId,
+                                        value);
+                            }
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientOnCharacteristicWrite);
+
+        DeviceShadowEnvironmentImpl.runOnService(serverAddress, onCharacteristicWriteRequest);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void readDescriptor(
+            final int clientIf,
+            final String serverAddress,
+            final int srvcType,
+            final int srvcInstId,
+            final ParcelUuid srvcId,
+            final int charInstId,
+            final ParcelUuid charId,
+            final int descrInstId,
+            final ParcelUuid descrId,
+            final int authReq) {
+        final String clientAddress = localAddress();
+        localGattDelegate()
+                .setLastRequest(
+                        new ReadDescriptorRequest(
+                                srvcType, srvcInstId, srvcId, charInstId, charId, descrInstId,
+                                descrId));
+
+        NamedRunnable serverOnDescriptorReadRequest =
+                NamedRunnable.create(
+                        "ServerGatt.onDescriptorReadRequest",
+                        () -> {
+                            int serverIf = localGattDelegate().getServerIf();
+                            IBluetoothGattServerCallback callback =
+                                    localGattDelegate().getServerCallback(serverIf);
+                            if (callback != null) {
+                                callback.onDescriptorReadRequest(
+                                        clientAddress,
+                                        0 /*transId*/,
+                                        0 /*offset*/,
+                                        false /*isLong*/,
+                                        0 /*srvcType*/,
+                                        srvcInstId,
+                                        srvcId,
+                                        charInstId,
+                                        charId,
+                                        descrId);
+                            }
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnDescriptorReadRequest);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void writeDescriptor(
+            final int clientIf,
+            final String serverAddress,
+            final int srvcType,
+            final int srvcInstId,
+            final ParcelUuid srvcId,
+            final int charInstId,
+            final ParcelUuid charId,
+            final int descrInstId,
+            final ParcelUuid descrId,
+            final int writeType,
+            final int authReq,
+            final byte[] value) {
+        // TODO(b/200231384): implement write with response needed.
+        remoteGattDelegate(serverAddress)
+                .getService(srvcId)
+                .getCharacteristic(charId)
+                .getDescriptor(descrId)
+                .setValue(value);
+        final String clientAddress = localAddress();
+
+        NamedRunnable serverOnDescriptorWriteRequest =
+                NamedRunnable.create(
+                        "ServerGatt.onDescriptorWriteRequest",
+                        () -> {
+                            int serverIf = localGattDelegate().getServerIf();
+                            IBluetoothGattServerCallback callback =
+                                    localGattDelegate().getServerCallback(serverIf);
+                            if (callback != null) {
+                                callback.onDescriptorWriteRequest(
+                                        clientAddress,
+                                        0 /*transId*/,
+                                        0 /*offset*/,
+                                        value.length,
+                                        false /*isPrep*/,
+                                        false /*needRsp*/,
+                                        0 /*srvcType*/,
+                                        srvcInstId,
+                                        srvcId,
+                                        charInstId,
+                                        charId,
+                                        descrId,
+                                        value);
+                            }
+                        });
+
+        NamedRunnable clientOnDescriptorWrite =
+                NamedRunnable.create(
+                        "ClientGatt.onDescriptorWrite",
+                        () -> {
+                            IBluetoothGattCallback callback = localGattDelegate().getClientCallback(
+                                    clientIf);
+                            if (callback != null) {
+                                callback.onDescriptorWrite(
+                                        serverAddress,
+                                        BluetoothGatt.GATT_SUCCESS,
+                                        0 /*srvcType*/,
+                                        srvcInstId,
+                                        srvcId,
+                                        charInstId,
+                                        charId,
+                                        descrInstId,
+                                        descrId);
+                            }
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnDescriptorWriteRequest);
+
+        DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientOnDescriptorWrite);
+    }
+
+    @Override
+    public void registerForNotification(
+            int clientIf,
+            String remoteAddress,
+            int srvcType,
+            int srvcInstId,
+            ParcelUuid srvcId,
+            int charInstId,
+            ParcelUuid charId,
+            boolean enable) {
+        remoteGattDelegate(remoteAddress)
+                .getService(srvcId)
+                .getCharacteristic(charId)
+                .registerNotification(localAddress(), clientIf);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void registerServer(ParcelUuid appId, final IBluetoothGattServerCallback callback) {
+        // TODO(b/200231384): support multiple serverIf.
+        final int serverIf = localGattDelegate().registerServer(callback);
+        NamedRunnable serverOnRegistered =
+                NamedRunnable.create(
+                        "ServerGatt.onServerRegistered",
+                        () -> {
+                            callback.onServerRegistered(BluetoothGatt.GATT_SUCCESS, serverIf);
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(localAddress(), serverOnRegistered);
+    }
+
+    @Override
+    public void unregisterServer(int serverIf) {
+        localGattDelegate().unregisterServer(serverIf);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void serverConnect(
+            final int serverIf, final String clientAddress, boolean isDirect, int transport) {
+        // TODO(b/200231384): implement isDirect and transport.
+        boolean success = localGattDelegate().connect(clientAddress);
+        final String serverAddress = localAddress();
+        if (!success) {
+            return;
+        }
+        int clientIf = remoteGattDelegate(clientAddress).getClientIf();
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                serverAddress,
+                newServerConnectionStateChangeRunnable(serverIf, true, clientAddress));
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                clientAddress,
+                newClientConnectionStateChangeRunnable(clientIf, true, serverAddress));
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void serverDisconnect(final int serverIf, final String clientAddress) {
+        localGattDelegate().disconnect(clientAddress);
+        String serverAddress = localAddress();
+        int clientIf = remoteGattDelegate(clientAddress).getClientIf();
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                serverAddress,
+                newServerConnectionStateChangeRunnable(serverIf, false, clientAddress));
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                clientAddress,
+                newClientConnectionStateChangeRunnable(clientIf, false, serverAddress));
+    }
+
+    @Override
+    public void beginServiceDeclaration(
+            int serverIf,
+            int srvcType,
+            int srvcInstId,
+            int minHandles,
+            ParcelUuid srvcId,
+            boolean advertisePreferred) {
+        // TODO(b/200231384): support different service type, instanceId, advertisePreferred.
+        mCurrentService = localGattDelegate().addService(srvcId);
+    }
+
+    @Override
+    public void addIncludedService(int serverIf, int srvcType, int srvcInstId, ParcelUuid srvcId) {
+        // TODO(b/200231384): implement this.
+    }
+
+    @Override
+    public void addCharacteristic(int serverIf, ParcelUuid charId, int properties,
+            int permissions) {
+        mCurrentCharacteristic = mCurrentService.addCharacteristic(charId, properties, permissions);
+    }
+
+    @Override
+    public void addDescriptor(int serverIf, ParcelUuid descId, int permissions) {
+        mCurrentCharacteristic.addDescriptor(descId, permissions);
+    }
+
+    @Override
+    public void endServiceDeclaration(int serverIf) {
+        // TODO(b/200231384): choose correct srvc type and inst id.
+        IBluetoothGattServerCallback callback = localGattDelegate().getServerCallback(serverIf);
+        if (callback != null) {
+            callback.onServiceAdded(
+                    BluetoothGatt.GATT_SUCCESS, 0 /*srvcType*/, 0 /*srvcInstId*/,
+                    mCurrentService.getUuid());
+        }
+        mCurrentService = null;
+    }
+
+    @Override
+    public void removeService(int serverIf, int srvcType, int srvcInstId, ParcelUuid srvcId) {
+        // TODO(b/200231384): implement remove service.
+        // localGattDelegate().removeService(srvcId);
+    }
+
+    @Override
+    public void clearServices(int serverIf) {
+        // TODO(b/200231384): support multiple serverIf.
+        // localGattDelegate().clearService();
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void sendResponse(
+            int serverIf, String clientAddress, int requestId, int status, int offset,
+            byte[] value) {
+        // TODO(b/200231384): implement more operations.
+        String serverAddress = localAddress();
+
+        DeviceShadowEnvironmentImpl.runOnService(
+                clientAddress,
+                NamedRunnable.create(
+                        "ClientGatt.receiveResponse",
+                        () -> {
+                            IBluetoothGattCallback callback =
+                                    localGattDelegate().getClientCallback(
+                                            localGattDelegate().getClientIf());
+                            if (callback != null) {
+                                Request request = localGattDelegate().getLastRequest();
+                                localGattDelegate().setLastRequest(null);
+                                if (request != null) {
+                                    if (request instanceof ReadCharacteristicRequest) {
+                                        callback.onCharacteristicRead(
+                                                serverAddress,
+                                                status,
+                                                request.mSrvcType,
+                                                request.mSrvcInstId,
+                                                request.mSrvcId,
+                                                request.mCharInstId,
+                                                request.mCharId,
+                                                value);
+                                    } else if (request instanceof ReadDescriptorRequest) {
+                                        ReadDescriptorRequest readDescriptorRequest =
+                                                (ReadDescriptorRequest) request;
+                                        callback.onDescriptorRead(
+                                                serverAddress,
+                                                status,
+                                                readDescriptorRequest.mSrvcType,
+                                                readDescriptorRequest.mSrvcInstId,
+                                                readDescriptorRequest.mSrvcId,
+                                                readDescriptorRequest.mCharInstId,
+                                                readDescriptorRequest.mCharId,
+                                                readDescriptorRequest.mDescrInstId,
+                                                readDescriptorRequest.mDescrId,
+                                                value);
+                                    }
+                                }
+                            }
+                        }));
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void sendNotification(
+            final int serverIf,
+            final String address,
+            final int srvcType,
+            final int srvcInstId,
+            final ParcelUuid srvcId,
+            final int charInstId,
+            final ParcelUuid charId,
+            boolean confirm,
+            final byte[] value) {
+        GattDelegate.Characteristic characteristic =
+                localGattDelegate().getService(srvcId).getCharacteristic(charId);
+        characteristic.setValue(value);
+        final String serverAddress = localAddress();
+        for (final String clientAddress : characteristic.getNotifyClients()) {
+            NamedRunnable clientOnNotify =
+                    NamedRunnable.create(
+                            "ClientGatt.onNotify",
+                            () -> {
+                                int clientIf = localGattDelegate().getClientIf();
+                                IBluetoothGattCallback callback =
+                                        localGattDelegate().getClientCallback(clientIf);
+                                if (callback != null) {
+                                    callback.onNotify(
+                                            serverAddress, srvcType, srvcInstId, srvcId, charInstId,
+                                            charId, value);
+                                }
+                            });
+
+            DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientOnNotify);
+        }
+
+        NamedRunnable serverOnNotificationSent =
+                NamedRunnable.create(
+                        "ServerGatt.onNotificationSent",
+                        () -> {
+                            IBluetoothGattServerCallback callback =
+                                    localGattDelegate().getServerCallback(serverIf);
+                            if (callback != null) {
+                                callback.onNotificationSent(address, BluetoothGatt.GATT_SUCCESS);
+                            }
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnNotificationSent);
+    }
+
+    @Override
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void configureMTU(int clientIf, String address, int mtu) {
+        final String clientAddress = localAddress();
+
+        NamedRunnable clientSetMtu =
+                NamedRunnable.create(
+                        "ClientGatt.setMtu",
+                        () -> {
+                            localGattDelegate().clientSetMtu(clientIf, mtu, address);
+                        });
+        NamedRunnable serverSetMtu =
+                NamedRunnable.create(
+                        "ServerGatt.setMtu",
+                        () -> {
+                            int serverIf = localGattDelegate().getServerIf();
+                            localGattDelegate().serverSetMtu(serverIf, mtu, clientAddress);
+                        });
+
+        DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientSetMtu);
+
+        DeviceShadowEnvironmentImpl.runOnService(address, serverSetMtu);
+    }
+
+    @Override
+    public void connectionParameterUpdate(int clientIf, String address, int connectionPriority) {
+        // TODO(b/200231384): Implement.
+    }
+
+    @Override
+    public void disconnectAll() {
+    }
+
+    @Override
+    public List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states) {
+        return new ArrayList<>();
+    }
+
+    @VisibleForTesting
+    static GattDelegate remoteGattDelegate(String address) {
+        return DeviceShadowEnvironmentImpl.getBlueletImpl(address).getGattDelegate();
+    }
+
+    private static GattDelegate localGattDelegate() {
+        return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().getGattDelegate();
+    }
+
+    private static String localAddress() {
+        return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().address;
+    }
+
+    private static NamedRunnable newClientConnectionStateChangeRunnable(
+            final int clientIf, final boolean isConnected, final String serverAddress) {
+        return NamedRunnable.create(
+                "ClientGatt.clientConnectionStateChange",
+                () -> {
+                    localGattDelegate()
+                            .clientConnectionStateChange(
+                                    BluetoothGatt.GATT_SUCCESS, clientIf, isConnected,
+                                    serverAddress);
+                });
+    }
+
+    private static NamedRunnable newServerConnectionStateChangeRunnable(
+            final int serverIf, final boolean isConnected, final String clientAddress) {
+        return NamedRunnable.create(
+                "ServerGatt.serverConnectionStateChange",
+                () -> {
+                    localGattDelegate()
+                            .serverConnectionStateChange(
+                                    BluetoothGatt.GATT_SUCCESS, serverIf, isConnected,
+                                    clientAddress);
+                });
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothImpl.java
new file mode 100644
index 0000000..ccf0ac3
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothImpl.java
@@ -0,0 +1,428 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.IBluetooth;
+import android.bluetooth.OobData;
+import android.content.AttributionSource;
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelUuid;
+
+import com.android.libraries.testing.deviceshadower.Bluelet.CreateBondOutcome;
+import com.android.libraries.testing.deviceshadower.Bluelet.FetchUuidsTiming;
+import com.android.libraries.testing.deviceshadower.Bluelet.IoCapabilities;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.AdapterDelegate.State;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl.PairingConfirmation;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+
+import java.util.Random;
+
+/**
+ * Implementation of IBluetooth interface.
+ */
+public class IBluetoothImpl implements IBluetooth {
+
+    private static final Logger LOGGER = Logger.create("BlueletImpl");
+
+    private enum PairingVariant {
+        JUST_WORKS,
+        /**
+         * AKA Passkey Confirmation.
+         */
+        NUMERIC_COMPARISON,
+        PASSKEY_INPUT,
+        CONSENT
+    }
+
+    /**
+     * User will be prompted to accept or deny the incoming pairing request.
+     */
+    private static final int PAIRING_VARIANT_CONSENT = 3;
+
+    /**
+     * User will be prompted to enter the passkey displayed on remote device. This is used for
+     * Bluetooth 2.1 pairing.
+     */
+    private static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4;
+
+    public IBluetoothImpl() {
+    }
+
+    @Override
+    public String getAddress() {
+        return localBlueletImpl().address;
+    }
+
+    @Override
+    public String getName() {
+        return localBlueletImpl().mName;
+    }
+
+    @Override
+    public boolean setName(String name) {
+        localBlueletImpl().mName = name;
+        return true;
+    }
+
+    @Override
+    public int getRemoteClass(BluetoothDevice device) {
+        return remoteBlueletImpl(device.getAddress()).getAdapterDelegate().getBluetoothClass();
+    }
+
+    @Override
+    public String getRemoteName(BluetoothDevice device) {
+        return remoteBlueletImpl(device.getAddress()).mName;
+    }
+
+    @Override
+    public int getRemoteType(BluetoothDevice device, AttributionSource attributionSource) {
+        return BluetoothDevice.DEVICE_TYPE_LE;
+    }
+
+    @Override
+    public ParcelUuid[] getRemoteUuids(BluetoothDevice device) {
+        return remoteBlueletImpl(device.getAddress()).mProfileUuids;
+    }
+
+    @Override
+    public boolean fetchRemoteUuids(BluetoothDevice device) {
+        localBlueletImpl().onFetchedUuids(device.getAddress(), getRemoteUuids(device));
+        return true;
+    }
+
+    @Override
+    public int getBondState(BluetoothDevice device, AttributionSource attributionSource) {
+        return localBlueletImpl().getBondState(device.getAddress());
+    }
+
+    @Override
+    public boolean createBond(BluetoothDevice device, int transport, OobData remoteP192Data,
+            OobData remoteP256Data, AttributionSource attributionSource) {
+        setBondState(device.getAddress(), BluetoothDevice.BOND_BONDING, BlueletImpl.REASON_SUCCESS);
+
+        BlueletImpl remoteBluelet = remoteBlueletImpl(device.getAddress());
+        BlueletImpl localBluelet = localBlueletImpl();
+
+        // Like the real Bluetooth stack, choose a pairing variant based on IO Capabilities.
+        // https://blog.bluetooth.com/bluetooth-pairing-part-2-key-generation-methods
+        PairingVariant variant = PairingVariant.JUST_WORKS;
+        if (localBluelet.getIoCapabilities() == IoCapabilities.DISPLAY_YES_NO) {
+            if (remoteBluelet.getIoCapabilities() == IoCapabilities.DISPLAY_YES_NO) {
+                variant = PairingVariant.NUMERIC_COMPARISON;
+            } else if (remoteBluelet.getIoCapabilities() == IoCapabilities.KEYBOARD_ONLY) {
+                variant = PairingVariant.PASSKEY_INPUT;
+            } else if (remoteBluelet.getIoCapabilities() == IoCapabilities.NO_INPUT_NO_OUTPUT
+                    && localBluelet.getEnableCVE20192225()) {
+                // After CVE-2019-2225, Bluetooth decides to ask consent instead of JustWorks.
+                variant = PairingVariant.CONSENT;
+            }
+        }
+
+        // Bonding doesn't complete until the passkey is confirmed on both devices. The passkey is a
+        // positive 6-digit integer, generated by the Bluetooth stack.
+        int passkey = new Random().nextInt(999999) + 1;
+        switch (variant) {
+            case NUMERIC_COMPARISON:
+                localBluelet.onPairingRequest(
+                        remoteBluelet.address, BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION,
+                        passkey);
+                remoteBluelet.onPairingRequest(
+                        localBluelet.address, BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION,
+                        passkey);
+                break;
+            case JUST_WORKS:
+                // Bonding completes immediately, with no PAIRING_REQUEST broadcast.
+                finishBonding(device);
+                break;
+            case PASSKEY_INPUT:
+                localBluelet.onPairingRequest(
+                        remoteBluelet.address, PAIRING_VARIANT_DISPLAY_PASSKEY, passkey);
+                localBluelet.mPassKey = passkey;
+                remoteBluelet.onPairingRequest(
+                        localBluelet.address, PAIRING_VARIANT_DISPLAY_PASSKEY, passkey);
+                break;
+            case CONSENT:
+                localBluelet.onPairingRequest(remoteBluelet.address,
+                        PAIRING_VARIANT_CONSENT, /* key= */ 0);
+                if (remoteBluelet.getIoCapabilities() == IoCapabilities.NO_INPUT_NO_OUTPUT) {
+                    remoteBluelet.setPairingConfirmation(localBluelet.address,
+                            PairingConfirmation.CONFIRMED);
+                } else {
+                    remoteBluelet.onPairingRequest(
+                            localBluelet.address, PAIRING_VARIANT_CONSENT, /* key= */ 0);
+                }
+                break;
+        }
+        return true;
+    }
+
+    private void finishBonding(BluetoothDevice device) {
+        BlueletImpl remoteBluelet = remoteBlueletImpl(device.getAddress());
+        finishBonding(
+                device, remoteBluelet.getCreateBondOutcome(),
+                remoteBluelet.getCreateBondFailureReason());
+    }
+
+    private void finishBonding(BluetoothDevice device, CreateBondOutcome outcome,
+            int failureReason) {
+        switch (outcome) {
+            case SUCCESS:
+                setBondState(device.getAddress(), BluetoothDevice.BOND_BONDED,
+                        BlueletImpl.REASON_SUCCESS);
+                break;
+            case FAILURE:
+                setBondState(device.getAddress(), BluetoothDevice.BOND_NONE, failureReason);
+                break;
+            case TIMEOUT:
+                // Send nothing.
+                break;
+        }
+    }
+
+    @Override
+    public boolean setPairingConfirmation(BluetoothDevice device, boolean confirmed,
+            AttributionSource attributionSource) {
+        localBlueletImpl()
+                .setPairingConfirmation(
+                        device.getAddress(),
+                        confirmed ? PairingConfirmation.CONFIRMED : PairingConfirmation.DENIED);
+
+        PairingConfirmation remoteConfirmation =
+                remoteBlueletImpl(device.getAddress()).getPairingConfirmation(
+                        localBlueletImpl().address);
+        if (confirmed && remoteConfirmation == PairingConfirmation.CONFIRMED) {
+            LOGGER.d(String.format("CONFIRMED"));
+            finishBonding(device);
+        } else if (!confirmed || remoteConfirmation == PairingConfirmation.DENIED) {
+            LOGGER.d(String.format("NOT CONFIRMED"));
+            finishBonding(device, CreateBondOutcome.FAILURE, BlueletImpl.UNBOND_REASON_AUTH_FAILED);
+        }
+        return true;
+    }
+
+    @Override
+    public boolean setPasskey(BluetoothDevice device, int passkey) {
+        BlueletImpl remoteBluelet = remoteBlueletImpl(device.getAddress());
+        if (passkey == remoteBluelet.mPassKey) {
+            finishBonding(device);
+        } else {
+            finishBonding(device, CreateBondOutcome.FAILURE, BlueletImpl.UNBOND_REASON_AUTH_FAILED);
+        }
+        return true;
+    }
+
+    @Override
+    public boolean cancelBondProcess(BluetoothDevice device) {
+        finishBonding(device, CreateBondOutcome.FAILURE, BlueletImpl.UNBOND_REASON_AUTH_CANCELED);
+        return true;
+    }
+
+    @Override
+    public boolean removeBond(BluetoothDevice device) {
+        setBondState(device.getAddress(), BluetoothDevice.BOND_NONE, BlueletImpl.REASON_SUCCESS);
+        return true;
+    }
+
+    @Override
+    public BluetoothDevice[] getBondedDevices() {
+        return localBlueletImpl().getBondedDevices();
+    }
+
+    @Override
+    public int getAdapterConnectionState() {
+        return localBlueletImpl().getAdapterConnectionState();
+    }
+
+    @Override
+    public int getProfileConnectionState(int profile) {
+        return localBlueletImpl().getProfileConnectionState(profile);
+    }
+
+    @Override
+    public int getPhonebookAccessPermission(BluetoothDevice device) {
+        return remoteBlueletImpl(device.getAddress()).mPhonebookAccessPermission;
+    }
+
+    @Override
+    public boolean setPhonebookAccessPermission(BluetoothDevice device, int value) {
+        remoteBlueletImpl(device.getAddress()).mPhonebookAccessPermission = value;
+        return true;
+    }
+
+    @Override
+    public int getMessageAccessPermission(BluetoothDevice device) {
+        return remoteBlueletImpl(device.getAddress()).mMessageAccessPermission;
+    }
+
+    @Override
+    public boolean setMessageAccessPermission(BluetoothDevice device, int value) {
+        remoteBlueletImpl(device.getAddress()).mMessageAccessPermission = value;
+        return true;
+    }
+
+    @Override
+    public int getSimAccessPermission(BluetoothDevice device) {
+        return remoteBlueletImpl(device.getAddress()).mSimAccessPermission;
+    }
+
+    @Override
+    public boolean setSimAccessPermission(BluetoothDevice device, int value) {
+        remoteBlueletImpl(device.getAddress()).mSimAccessPermission = value;
+        return true;
+    }
+
+    private static void setBondState(String remoteAddress, int state, int failureReason) {
+        BlueletImpl remoteBluelet = remoteBlueletImpl(remoteAddress);
+
+        if (remoteBluelet.getFetchUuidsTiming() == FetchUuidsTiming.BEFORE_BONDING) {
+            fetchUuidsOnBondedState(remoteAddress, state);
+        }
+
+        remoteBluelet.setBondState(localBlueletImpl().address, state, failureReason);
+        localBlueletImpl().setBondState(remoteAddress, state, failureReason);
+
+        if (remoteBluelet.getFetchUuidsTiming() == FetchUuidsTiming.AFTER_BONDING) {
+            fetchUuidsOnBondedState(remoteAddress, state);
+        }
+    }
+
+    private static void fetchUuidsOnBondedState(String remoteAddress, int state) {
+        if (state == BluetoothDevice.BOND_BONDED) {
+            remoteBlueletImpl(remoteAddress)
+                    .onFetchedUuids(localBlueletImpl().address, localBlueletImpl().mProfileUuids);
+            localBlueletImpl()
+                    .onFetchedUuids(remoteAddress, remoteBlueletImpl(remoteAddress).mProfileUuids);
+        }
+    }
+
+    @Override
+    public int getScanMode() {
+        return localBlueletImpl().getAdapterDelegate().getScanMode();
+    }
+
+    @Override
+    public boolean setScanMode(int mode, int duration) {
+        localBlueletImpl().getAdapterDelegate().setScanMode(mode);
+        return true;
+    }
+
+    @Override
+    public int getDiscoverableTimeout() {
+        return -1;
+    }
+
+    @Override
+    public boolean setDiscoverableTimeout(int timeout) {
+        return true;
+    }
+
+    @Override
+    public boolean startDiscovery() {
+        localBlueletImpl().getAdapterDelegate().startDiscovery();
+        return true;
+    }
+
+    @Override
+    public boolean cancelDiscovery() {
+        localBlueletImpl().getAdapterDelegate().cancelDiscovery();
+        return true;
+    }
+
+    @Override
+    public boolean isDiscovering() {
+        return localBlueletImpl().getAdapterDelegate().isDiscovering();
+
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return localBlueletImpl().getAdapterDelegate().getState().equals(State.ON);
+    }
+
+    @Override
+    public int getState() {
+        return localBlueletImpl().getAdapterDelegate().getState().getValue();
+    }
+
+    @Override
+    public boolean enable() {
+        localBlueletImpl().enableAdapter();
+        return true;
+    }
+
+    @Override
+    public boolean disable() {
+        localBlueletImpl().disableAdapter();
+        return true;
+    }
+
+    @Override
+    public ParcelFileDescriptor connectSocket(BluetoothDevice device, int type, ParcelUuid uuid,
+            int port, int flag) {
+        Preconditions.checkArgument(
+                port == BluetoothConstants.SOCKET_CHANNEL_CONNECT_WITH_UUID,
+                "Connect to port is not supported.");
+        Preconditions.checkArgument(
+                type == BluetoothConstants.TYPE_RFCOMM,
+                "Only Rfcomm socket is supported.");
+        return localBlueletImpl().getRfcommDelegate()
+                .connectSocket(device.getAddress(), uuid.getUuid());
+    }
+
+    @Override
+    public ParcelFileDescriptor createSocketChannel(int type, String serviceName, ParcelUuid uuid,
+            int port, int flag) {
+        Preconditions.checkArgument(
+                port == BluetoothConstants.SERVER_SOCKET_CHANNEL_AUTO_ASSIGN,
+                "Listen on port is not supported.");
+        Preconditions.checkArgument(
+                type == BluetoothConstants.TYPE_RFCOMM,
+                "Only Rfcomm socket is supported.");
+        return localBlueletImpl().getRfcommDelegate().createSocketChannel(serviceName, uuid);
+    }
+
+    @Override
+    public boolean isMultiAdvertisementSupported() {
+        return maxAdvertiseInstances() > 1;
+    }
+
+    @Override
+    public boolean isPeripheralModeSupported() {
+        return maxAdvertiseInstances() > 0;
+    }
+
+    private int maxAdvertiseInstances() {
+        return localBlueletImpl().getGattDelegate().getMaxAdvertiseInstances();
+    }
+
+    @Override
+    public boolean isOffloadedFilteringSupported() {
+        return localBlueletImpl().getGattDelegate().isOffloadedFilteringSupported();
+    }
+
+    private static BlueletImpl localBlueletImpl() {
+        return DeviceShadowEnvironmentImpl.getLocalBlueletImpl();
+    }
+
+    private static BlueletImpl remoteBlueletImpl(String address) {
+        return DeviceShadowEnvironmentImpl.getBlueletImpl(address);
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothManagerImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothManagerImpl.java
new file mode 100644
index 0000000..cb38a41
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothManagerImpl.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.IBluetooth;
+import android.bluetooth.IBluetoothGatt;
+import android.bluetooth.IBluetoothManager;
+import android.bluetooth.IBluetoothManagerCallback;
+
+/**
+ * Implementation of IBluetoothManager interface
+ */
+public class IBluetoothManagerImpl implements IBluetoothManager {
+
+    private final IBluetooth mFakeBluetoothService = new IBluetoothImpl();
+    private final IBluetoothGatt mFakeGattService = new IBluetoothGattImpl();
+
+    @Override
+    public String getAddress() {
+        return mFakeBluetoothService.getAddress();
+    }
+
+    @Override
+    public String getName() {
+        return mFakeBluetoothService.getName();
+    }
+
+    @Override
+    public IBluetooth registerAdapter(IBluetoothManagerCallback callback) {
+        return mFakeBluetoothService;
+    }
+
+    @Override
+    public IBluetoothGatt getBluetoothGatt() {
+        return mFakeGattService;
+    }
+
+    @Override
+    public boolean enable() {
+        mFakeBluetoothService.enable();
+        return true;
+    }
+
+    @Override
+    public boolean disable(boolean persist) {
+        mFakeBluetoothService.disable();
+        return true;
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/FileDescriptorFactory.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/FileDescriptorFactory.java
new file mode 100644
index 0000000..12fa587
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/FileDescriptorFactory.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import java.io.FileDescriptor;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Factory which creates {@link FileDescriptor} given an MAC address. Each MAC address can have many
+ * FileDescriptor but each FileDescriptor only maps to one MAC address.
+ */
+public class FileDescriptorFactory {
+
+    private static FileDescriptorFactory sInstance = null;
+
+    public static synchronized FileDescriptorFactory getInstance() {
+        if (sInstance == null) {
+            sInstance = new FileDescriptorFactory();
+        }
+        return sInstance;
+    }
+
+    public static synchronized void reset() {
+        sInstance = null;
+    }
+
+    private final Map<FileDescriptor, String> mAddressMap;
+
+    private FileDescriptorFactory() {
+        mAddressMap = new ConcurrentHashMap<>();
+    }
+
+    public FileDescriptor createFileDescriptor(String address) {
+        FileDescriptor fd = new FileDescriptor();
+        mAddressMap.put(fd, address);
+        return fd;
+    }
+
+    public String getAddress(FileDescriptor fd) {
+        return mAddressMap.get(fd);
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PageScanHandler.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PageScanHandler.java
new file mode 100644
index 0000000..82b97ff
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PageScanHandler.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import android.os.Build.VERSION;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.utils.MacAddressGenerator;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
+
+/**
+ * Encapsulate page scan operations -- handle connection establishment between Bluetooth devices.
+ */
+public class PageScanHandler {
+
+    private static final ConnectionRequest REQUEST_SERVER_SOCKET_CLOSE = new ConnectionRequest();
+
+    private static PageScanHandler sInstance = null;
+
+    public static synchronized PageScanHandler getInstance() {
+        if (sInstance == null) {
+            sInstance = new PageScanHandler();
+        }
+        return sInstance;
+    }
+
+    public static synchronized void reset() {
+        sInstance = null;
+    }
+
+    // use FileDescriptor to identify incoming data before socket is connected.
+    private final Map<FileDescriptor, BlockingQueue<Integer>> mIncomingDataMap;
+    // map a server socket fd to a connection request queue
+    private final Map<FileDescriptor, BlockingQueue<ConnectionRequest>> mConnectionRequests;
+    // map a fd on client side to a fd of BluetoothSocket(not BluetoothServerSocket) on server side
+    private final Map<FileDescriptor, FileDescriptor> mClientServerFdMap;
+    // map a client fd to a connection request so the client socket can finish the pending
+    // connection
+    private final Map<FileDescriptor, ConnectionRequest> mPendingConnections;
+
+    private PageScanHandler() {
+        mIncomingDataMap = new ConcurrentHashMap<>();
+        mConnectionRequests = new ConcurrentHashMap<>();
+        mClientServerFdMap = new ConcurrentHashMap<>();
+        mPendingConnections = new ConcurrentHashMap<>();
+    }
+
+    public void postConnectionRequest(FileDescriptor serverSocketFd, ConnectionRequest request)
+            throws InterruptedException {
+        // used by the returning socket on server-side
+        FileDescriptor fd = FileDescriptorFactory.getInstance()
+                .createFileDescriptor(request.mServerAddress);
+        mClientServerFdMap.put(request.mClientFd, fd);
+        BlockingQueue<ConnectionRequest> requests = mConnectionRequests.get(serverSocketFd);
+        requests.put(request);
+        mPendingConnections.put(request.mClientFd, request);
+    }
+
+    public void addServerSocket(FileDescriptor serverSocketFd) {
+        mConnectionRequests.put(serverSocketFd, new LinkedBlockingQueue<ConnectionRequest>());
+    }
+
+    public FileDescriptor getServerFd(FileDescriptor clientFd) {
+        return mClientServerFdMap.get(clientFd);
+    }
+
+    // TODO(b/79994182): see go/objecttostring-lsc
+    @SuppressWarnings("ObjectToString")
+    public FileDescriptor processNextConnectionRequest(FileDescriptor serverSocketFd)
+            throws IOException, InterruptedException {
+        ConnectionRequest request = mConnectionRequests.get(serverSocketFd).take();
+        if (request == REQUEST_SERVER_SOCKET_CLOSE) {
+            // TODO(b/79994182): FileDescriptor does not implement toString() in serverSocketFd
+            throw new IOException("Server socket is closed. fd: " + serverSocketFd);
+        }
+        writeInitialConnectionInfo(serverSocketFd, request.mClientAddress, request.mPort);
+        return request.mClientFd;
+    }
+
+    public void waitForConnectionEstablished(FileDescriptor clientFd) throws InterruptedException {
+        ConnectionRequest request = mPendingConnections.get(clientFd);
+        if (request != null) {
+            request.mCountDownLatch.await();
+        }
+    }
+
+    public void finishPendingConnection(FileDescriptor clientFd) {
+        ConnectionRequest request = mPendingConnections.get(clientFd);
+        if (request != null) {
+            request.mCountDownLatch.countDown();
+        }
+    }
+
+    public void cancelServerSocket(FileDescriptor serverSocketFd) throws InterruptedException {
+        mConnectionRequests.get(serverSocketFd).put(REQUEST_SERVER_SOCKET_CLOSE);
+    }
+
+    public void writeInitialConnectionInfo(FileDescriptor fd, String address, int port)
+            throws InterruptedException {
+        for (byte b : initialConnectionInfo(address, port)) {
+            write(fd, Integer.valueOf(b));
+        }
+    }
+
+    public void writePort(FileDescriptor fd, int port) throws InterruptedException {
+        byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(port).array();
+        for (byte b : bytes) {
+            write(fd, Integer.valueOf(b));
+        }
+    }
+
+    public void write(FileDescriptor fd, int data) throws InterruptedException {
+        BlockingQueue<Integer> incomingData = mIncomingDataMap.get(fd);
+        if (incomingData == null) {
+            synchronized (mIncomingDataMap) {
+                incomingData = mIncomingDataMap.get(fd);
+                if (incomingData == null) {
+                    incomingData = new LinkedBlockingQueue<Integer>();
+                    mIncomingDataMap.put(fd, incomingData);
+                }
+            }
+        }
+        incomingData.put(data);
+    }
+
+    public int read(FileDescriptor fd) throws InterruptedException {
+        return mIncomingDataMap.get(fd).take();
+    }
+
+    /**
+     * A connection request from a {@link android.bluetooth.BluetoothSocket}.
+     */
+    @VisibleForTesting
+    public static class ConnectionRequest {
+
+        final FileDescriptor mClientFd;
+        final String mClientAddress;
+        final String mServerAddress;
+        final int mPort;
+        final CountDownLatch mCountDownLatch; // block server socket until connection established
+
+        public ConnectionRequest(FileDescriptor fd, String clientAddress, String serverAddress,
+                int port) {
+            mClientFd = fd;
+            this.mClientAddress = clientAddress;
+            this.mServerAddress = serverAddress;
+            this.mPort = port;
+            mCountDownLatch = new CountDownLatch(1);
+        }
+
+        private ConnectionRequest() {
+            mClientFd = null;
+            mClientAddress = null;
+            mServerAddress = null;
+            mPort = -1;
+            mCountDownLatch = new CountDownLatch(0);
+        }
+    }
+
+    private static byte[] initialConnectionInfo(String addr, int port) {
+        byte[] mac = MacAddressGenerator.convertStringMacAddress(addr);
+        int channel = port;
+        int status = 0;
+
+        if (VERSION.SDK_INT < 23) {
+            byte[] signal = new byte[16];
+            short signalSize = 16;
+            ByteBuffer buffer = ByteBuffer.wrap(signal);
+            buffer.order(ByteOrder.LITTLE_ENDIAN)
+                    .putShort(signalSize)
+                    .put(mac)
+                    .putInt(channel)
+                    .putInt(status);
+            return buffer.array();
+        } else {
+            byte[] signal = new byte[20];
+            short signalSize = 20;
+            short maxTxPacketSize = 10000;
+            short maxRxPacketSize = 10000;
+            ByteBuffer buffer = ByteBuffer.wrap(signal);
+            buffer.order(ByteOrder.LITTLE_ENDIAN)
+                    .putShort(signalSize)
+                    .put(mac)
+                    .putInt(channel)
+                    .putInt(status)
+                    .putShort(maxTxPacketSize)
+                    .putShort(maxRxPacketSize);
+            return buffer.array();
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PhysicalLink.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PhysicalLink.java
new file mode 100644
index 0000000..e474c69
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PhysicalLink.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.collect.Sets;
+
+import java.io.FileDescriptor;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A class represents a physical link for communications between two Bluetooth devices.
+ */
+public class PhysicalLink {
+
+    // Intended to use RfcommDelegate
+    private static final Logger LOGGER = Logger.create("RfcommDelegate");
+
+    private final Object mLock;
+    // Every socket has unique FileDescriptor, so use it as socket identifier during communication
+    private final Map<FileDescriptor, RfcommSocketConnection> mConnectionLookup;
+    // Map fd of a socket to the fd of the other socket it connects to
+    private final Map<FileDescriptor, FileDescriptor> mFdMap;
+    private final Set<RfcommSocketConnection> mConnections;
+    private final AtomicBoolean mIsEncrypted;
+    private final Map<String, RfcommDelegate.Callback> mCallbacks = new HashMap<>();
+
+    public PhysicalLink(String address1, String address2) {
+        this(address1,
+                DeviceShadowEnvironmentImpl.getBlueletImpl(address1).getRfcommDelegate().mCallback,
+                address2,
+                DeviceShadowEnvironmentImpl.getBlueletImpl(address2).getRfcommDelegate().mCallback,
+                new ConcurrentHashMap<FileDescriptor, RfcommSocketConnection>(),
+                new ConcurrentHashMap<FileDescriptor, FileDescriptor>(),
+                Sets.<RfcommSocketConnection>newConcurrentHashSet());
+    }
+
+    @VisibleForTesting
+    PhysicalLink(String address1, RfcommDelegate.Callback callback1,
+            String address2, RfcommDelegate.Callback callback2,
+            Map<FileDescriptor, RfcommSocketConnection> connectionLookup,
+            Map<FileDescriptor, FileDescriptor> fdMap,
+            Set<RfcommSocketConnection> connections) {
+        mLock = new Object();
+        mCallbacks.put(address1, callback1);
+        mCallbacks.put(address2, callback2);
+        this.mConnectionLookup = connectionLookup;
+        this.mFdMap = fdMap;
+        this.mConnections = connections;
+        mIsEncrypted = new AtomicBoolean(false);
+    }
+
+    public void addConnection(FileDescriptor fd1, FileDescriptor fd2) {
+        synchronized (mLock) {
+            int oldSize = mConnections.size();
+            RfcommSocketConnection connection = new RfcommSocketConnection(
+                    FileDescriptorFactory.getInstance().getAddress(fd1),
+                    FileDescriptorFactory.getInstance().getAddress(fd2)
+            );
+            mConnections.add(connection);
+            mConnectionLookup.put(fd1, connection);
+            mConnectionLookup.put(fd2, connection);
+            mFdMap.put(fd1, fd2);
+            mFdMap.put(fd2, fd1);
+            if (oldSize == 0) {
+                onConnectionStateChange(true);
+            }
+        }
+    }
+
+    // TODO(b/79994182): see go/objecttostring-lsc
+    @SuppressWarnings("ObjectToString")
+    public void closeConnection(FileDescriptor fd) {
+        // check for early return without locking
+        if (!mConnectionLookup.containsKey(fd)) {
+            // TODO(b/79994182): FileDescriptor does not implement toString() in fd
+            LOGGER.d("Connection doesn't exist, FileDescriptor: " + fd);
+            return;
+        }
+        synchronized (mLock) {
+            RfcommSocketConnection connection = mConnectionLookup.get(fd);
+            if (connection == null) {
+                // TODO(b/79994182): FileDescriptor does not implement toString() in fd
+                LOGGER.d("Connection doesn't exist, FileDescriptor: " + fd);
+                return;
+            }
+            int oldSize = mConnections.size();
+            FileDescriptor connectingFd = mFdMap.get(fd);
+            mConnectionLookup.remove(fd);
+            mConnectionLookup.remove(connectingFd);
+            mFdMap.remove(fd);
+            mFdMap.remove(connectingFd);
+            mConnections.remove(connection);
+            if (oldSize == 1) {
+                onConnectionStateChange(false);
+            }
+        }
+    }
+
+    public RfcommSocketConnection getConnection(FileDescriptor fd) {
+        return mConnectionLookup.get(fd);
+    }
+
+    public void encrypt() {
+        mIsEncrypted.set(true);
+    }
+
+    public boolean isEncrypted() {
+        return mIsEncrypted.get();
+    }
+
+    public boolean isConnected() {
+        return !mConnections.isEmpty();
+    }
+
+    private void onConnectionStateChange(boolean isConnected) {
+        for (Entry<String, RfcommDelegate.Callback> entry : mCallbacks.entrySet()) {
+            RfcommDelegate.Callback callback = entry.getValue();
+            String localAddress = entry.getKey();
+            callback.onConnectionStateChange(getRemoteAddress(localAddress), isConnected);
+        }
+    }
+
+    private String getRemoteAddress(String address) {
+        String remoteAddress = null;
+        for (String addr : mCallbacks.keySet()) {
+            if (!addr.equals(address)) {
+                remoteAddress = addr;
+                break;
+            }
+        }
+        return remoteAddress;
+    }
+
+    /**
+     * Represents a Rfcomm socket connection between two {@link android.bluetooth.BluetoothSocket}.
+     */
+    public static class RfcommSocketConnection {
+
+        final Map<String, BlockingQueue<Integer>> mIncomingDataMap; // address : incomingData
+
+        public RfcommSocketConnection(String address1, String address2) {
+            mIncomingDataMap = new ConcurrentHashMap<>();
+            mIncomingDataMap.put(address1, new LinkedBlockingQueue<Integer>());
+            mIncomingDataMap.put(address2, new LinkedBlockingQueue<Integer>());
+        }
+
+        public void write(String address, int b) throws InterruptedException {
+            mIncomingDataMap.get(address).put(b);
+        }
+
+        public int read(String address) throws InterruptedException {
+            return mIncomingDataMap.get(address).take();
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/RfcommDelegate.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/RfcommDelegate.java
new file mode 100644
index 0000000..3a4fdf6
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/RfcommDelegate.java
@@ -0,0 +1,367 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelUuid;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BluetoothConstants;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.PageScanHandler.ConnectionRequest;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.PhysicalLink.RfcommSocketConnection;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.SdpHandler.ServiceRecord;
+import com.android.libraries.testing.deviceshadower.internal.common.Interrupter;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.errorprone.annotations.FormatMethod;
+
+import org.robolectric.util.ReflectionHelpers;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Delegate for Bluetooth Rfcommon operations, including creating service record, establishing
+ * connection, and data communications.
+ * <p>Socket connection with uuid is supported. Listen on port and connect to port are not
+ * supported.</p>
+ */
+public class RfcommDelegate {
+
+    private static final Logger LOGGER = Logger.create("RfcommDelegate");
+    private static final Object LOCK = new Object();
+
+    /**
+     * Callback for Rfcomm operations
+     */
+    public interface Callback {
+
+        void onConnectionStateChange(String remoteAddress, boolean isConnected);
+    }
+
+    public static void reset() {
+        PageScanHandler.reset();
+        FileDescriptorFactory.reset();
+    }
+
+    final Callback mCallback;
+    private final String mAddress;
+    private final Interrupter mInterrupter;
+    private final SdpHandler mSdpHandler;
+    private final PageScanHandler mPageScanHandler;
+    private final Map<String, PhysicalLink> mConnectionMap; // remoteAddress : physicalLink
+
+    public RfcommDelegate(String address, Callback callback, Interrupter interrupter) {
+        this.mAddress = address;
+        this.mCallback = callback;
+        this.mInterrupter = interrupter;
+        mSdpHandler = new SdpHandler(address);
+        mPageScanHandler = PageScanHandler.getInstance();
+        mConnectionMap = new ConcurrentHashMap<>();
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public ParcelFileDescriptor createSocketChannel(String serviceName, ParcelUuid uuid) {
+        ServiceRecord record = mSdpHandler.createServiceRecord(uuid.getUuid(), serviceName);
+        if (record == null) {
+            LOGGER.e(
+                    String.format("Address %s: failed to create socket channel, uuid: %s", mAddress,
+                            uuid));
+            return null;
+        }
+        try {
+            mPageScanHandler.writePort(record.mServerSocketFd, record.mPort);
+        } catch (InterruptedException e) {
+            LOGGER.e(String.format("Address %s: failed to write port to incoming data, fd: %s",
+                    mAddress,
+                    record.mServerSocketFd), e);
+            return null;
+        }
+        return parcelFileDescriptor(record.mServerSocketFd);
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public ParcelFileDescriptor connectSocket(String remoteAddress, UUID uuid) {
+        BlueletImpl remote = DeviceShadowEnvironmentImpl.getBlueletImpl(remoteAddress);
+        if (remote == null) {
+            LOGGER.e(String.format("Device %s is not defined.", remoteAddress));
+            return null;
+        }
+        ServiceRecord record = remote.getRfcommDelegate().mSdpHandler.lookupChannel(uuid);
+        if (record == null) {
+            LOGGER.e(String.format("Address %s: failed to connect socket, uuid: %s", mAddress,
+                    uuid));
+            return null;
+        }
+        FileDescriptor fd = FileDescriptorFactory.getInstance().createFileDescriptor(mAddress);
+        try {
+            mPageScanHandler.writePort(fd, record.mPort);
+        } catch (InterruptedException e) {
+            LOGGER.e(String.format("Address %s: failed to write port to incoming data, fd: %s",
+                    mAddress,
+                    fd), e);
+            return null;
+        }
+
+        // establish connection
+        try {
+            initiateConnectToServer(fd, record, remoteAddress);
+        } catch (IOException e) {
+            LOGGER.e(
+                    String.format("Address %s: fail to initiate connection to server, clientFd: %s",
+                            mAddress, fd), e);
+            return null;
+        }
+        return parcelFileDescriptor(fd);
+    }
+
+    /**
+     * Creates connection and unblocks server socket.
+     * <p>ShadowBluetoothSocket calls the method at the end of connect().</p>
+     */
+    public void finishPendingConnection(
+            String serverAddress, FileDescriptor clientFd, boolean isEncrypted) {
+        // update states
+        PhysicalLink physicalChannel = mConnectionMap.get(serverAddress);
+        if (physicalChannel == null) {
+            // use class level lock to ensure two RfcommDelegate hold reference to the same Physical
+            // Link
+            synchronized (LOCK) {
+                physicalChannel = mConnectionMap.get(serverAddress);
+                if (physicalChannel == null) {
+                    physicalChannel = new PhysicalLink(
+                            serverAddress,
+                            FileDescriptorFactory.getInstance().getAddress(clientFd));
+                    addPhysicalChannel(serverAddress, physicalChannel);
+                    BlueletImpl remote = DeviceShadowEnvironmentImpl.getBlueletImpl(serverAddress);
+                    remote.getRfcommDelegate().addPhysicalChannel(mAddress, physicalChannel);
+                }
+            }
+        }
+        physicalChannel.addConnection(clientFd, mPageScanHandler.getServerFd(clientFd));
+
+        if (isEncrypted) {
+            physicalChannel.encrypt();
+        }
+        mPageScanHandler.finishPendingConnection(clientFd);
+    }
+
+    /**
+     * Process the next {@link ConnectionRequest} to {@link android.bluetooth.BluetoothServerSocket}
+     * identified by serverSocketFd. This call will block until next connection request is
+     * available.
+     */
+    @SuppressWarnings("ObjectToString")
+    public FileDescriptor processNextConnectionRequest(FileDescriptor serverSocketFd)
+            throws IOException {
+        try {
+            return mPageScanHandler.processNextConnectionRequest(serverSocketFd);
+        } catch (InterruptedException e) {
+            throw new IOException(
+                    logError(e, "failed to process next connection request, serverSocketFd: %s",
+                            serverSocketFd),
+                    e);
+        }
+    }
+
+    /**
+     * Waits for a connection established.
+     * <p>ShadowBluetoothServerSocket calls the method at the end of accept(). Ensure that a
+     * connection is established when accept() returns.</p>
+     */
+    @SuppressWarnings("ObjectToString")
+    public void waitForConnectionEstablished(FileDescriptor clientFd) throws IOException {
+        try {
+            mPageScanHandler.waitForConnectionEstablished(clientFd);
+        } catch (InterruptedException e) {
+            throw new IOException(
+                    logError(e, "failed to wait for connection established. clientFd: %s",
+                            clientFd), e);
+        }
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public void write(String remoteAddress, FileDescriptor localFd, int b)
+            throws IOException {
+        checkInterrupt();
+        RfcommSocketConnection connection =
+                mConnectionMap.get(remoteAddress).getConnection(localFd);
+        if (connection == null) {
+            throw new IOException("closed");
+        }
+        try {
+            connection.write(remoteAddress, b);
+        } catch (InterruptedException e) {
+            throw new IOException(
+                    logError(e, "failed to write to target %s, fd: %s", remoteAddress,
+                            localFd), e);
+        }
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public int read(String remoteAddress, FileDescriptor localFd) throws IOException {
+        checkInterrupt();
+        // remoteAddress is null: 1. server socket, 2. client socket before connected
+        try {
+            if (remoteAddress == null) {
+                return mPageScanHandler.read(localFd);
+            }
+        } catch (InterruptedException e) {
+            throw new IOException(logError(e, "failed to read, fd: %s", localFd), e);
+        }
+
+        RfcommSocketConnection connection =
+                mConnectionMap.get(remoteAddress).getConnection(localFd);
+        if (connection == null) {
+            throw new IOException("closed");
+        }
+        try {
+            return connection.read(mAddress);
+        } catch (InterruptedException e) {
+            throw new IOException(logError(e, "failed to read, fd: %s", localFd), e);
+        }
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public void shutdownInput(String remoteAddress, FileDescriptor localFd)
+            throws IOException {
+        // remoteAddress is null: 1. server socket, 2. client socket before connected
+        try {
+            if (remoteAddress == null) {
+                mPageScanHandler.write(localFd, BluetoothConstants.SOCKET_CLOSE);
+                return;
+            }
+        } catch (InterruptedException e) {
+            throw new IOException(logError(e, "failed to shutdown input. fd: %s", localFd), e);
+        }
+
+        RfcommSocketConnection connection =
+                mConnectionMap.get(remoteAddress).getConnection(localFd);
+        if (connection == null) {
+            LOGGER.d(String.format("Address %s: Connection already closed. fd: %s.", mAddress,
+                    localFd));
+            return;
+        }
+        try {
+            connection.write(mAddress, BluetoothConstants.SOCKET_CLOSE);
+        } catch (InterruptedException e) {
+            throw new IOException(logError(e, "failed to shutdown input. fd: %s", localFd), e);
+        }
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public void shutdownOutput(String remoteAddress, FileDescriptor localFd)
+            throws IOException {
+        RfcommSocketConnection connection =
+                mConnectionMap.get(remoteAddress).getConnection(localFd);
+        if (connection == null) {
+            LOGGER.d(String.format("Address %s: Connection already closed. fd: %s.", mAddress,
+                    localFd));
+            return;
+        }
+        try {
+            connection.write(remoteAddress, BluetoothConstants.SOCKET_CLOSE);
+        } catch (InterruptedException e) {
+            throw new IOException(logError(e, "failed to shutdown output. fd: %s", localFd), e);
+        }
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public void closeServerSocket(FileDescriptor serverSocketFd) throws IOException {
+        // remove service record
+        UUID uuid = mSdpHandler.getUuid(serverSocketFd);
+        mSdpHandler.removeServiceRecord(uuid);
+        // unblock accept()
+        try {
+            mPageScanHandler.cancelServerSocket(serverSocketFd);
+        } catch (InterruptedException e) {
+            throw new IOException(
+                    logError(e, "failed to cancel server socket, serverSocketFd: %s",
+                            serverSocketFd),
+                    e);
+        }
+    }
+
+    public FileDescriptor getServerFd(FileDescriptor clientFd) {
+        return mPageScanHandler.getServerFd(clientFd);
+    }
+
+    @VisibleForTesting
+    public void addPhysicalChannel(String remoteAddress, PhysicalLink channel) {
+        mConnectionMap.put(remoteAddress, channel);
+    }
+
+    @SuppressWarnings("ObjectToString")
+    public void initiateConnectToClient(FileDescriptor clientFd, int port)
+            throws IOException {
+        checkInterrupt();
+        String clientAddress = FileDescriptorFactory.getInstance().getAddress(clientFd);
+        LOGGER.d(String.format("Address %s: init connection to %s, clientFd: %s",
+                mAddress, clientAddress, clientFd));
+        try {
+            mPageScanHandler.writeInitialConnectionInfo(clientFd, mAddress, port);
+        } catch (InterruptedException e) {
+            throw new IOException(
+                    logError(e,
+                            "failed to write initial connection info to %s, clientFd: %s",
+                            clientAddress, clientFd),
+                    e);
+        }
+    }
+
+    @SuppressWarnings("ObjectToString")
+    private void initiateConnectToServer(FileDescriptor clientFd, ServiceRecord serviceRecord,
+            String serverAddress) throws IOException {
+        checkInterrupt();
+        LOGGER.d(
+                String.format("Address %s: init connection to %s, serverSocketFd: %s, clientFd: %s",
+                        mAddress, serverAddress, serviceRecord.mServerSocketFd, clientFd));
+        try {
+            ConnectionRequest request = new ConnectionRequest(clientFd, mAddress, serverAddress,
+                    serviceRecord.mPort);
+            mPageScanHandler.postConnectionRequest(serviceRecord.mServerSocketFd, request);
+        } catch (InterruptedException e) {
+            throw new IOException(
+                    logError(e,
+                            "failed to post connection request, serverSocketFd: %s, "
+                                    + "clientFd: %s",
+                            serviceRecord.mServerSocketFd, clientFd),
+                    e);
+        }
+    }
+
+    public void checkInterrupt() throws IOException {
+        mInterrupter.checkInterrupt();
+    }
+
+    private ParcelFileDescriptor parcelFileDescriptor(FileDescriptor fd) {
+        return ReflectionHelpers.callConstructor(ParcelFileDescriptor.class,
+                ReflectionHelpers.ClassParameter.from(FileDescriptor.class, fd));
+    }
+
+    @FormatMethod
+    private String logError(Exception e, String msgTmpl, Object... args) {
+        String errMsg = String.format("Address %s: ", mAddress) + String.format(msgTmpl, args);
+        LOGGER.e(errMsg, e);
+        return errMsg;
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/SdpHandler.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/SdpHandler.java
new file mode 100644
index 0000000..dbe8651
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/SdpHandler.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Sets;
+
+import java.io.FileDescriptor;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Encapsulates SDP operations including creating service record and allocating channel.
+ * <p>Listen on port and connect on port are not supported. </p>
+ */
+public class SdpHandler {
+
+    // intended to use "RfcommDelegate"
+    private static final Logger LOGGER = Logger.create("RfcommDelegate");
+
+    private final Object mLock;
+    private final String mAddress;
+    private final Map<UUID, ServiceRecord> mServiceRecords;
+    private final Map<FileDescriptor, UUID> mFdUuidMap;
+    private final Set<Integer> mAvailablePortPool;
+    private final Set<Integer> mInUsePortPool;
+
+    public SdpHandler(String address) {
+        mLock = new Object();
+        this.mAddress = address;
+        mServiceRecords = new ConcurrentHashMap<>();
+        mFdUuidMap = new ConcurrentHashMap<>();
+        mAvailablePortPool = Sets.newConcurrentHashSet();
+        mInUsePortPool = Sets.newConcurrentHashSet();
+        // 1 to 30 are valid RFCOMM port
+        for (int i = 1; i <= 30; i++) {
+            mAvailablePortPool.add(i);
+        }
+    }
+
+    public ServiceRecord createServiceRecord(UUID uuid, String serviceName) {
+        Preconditions.checkNotNull(uuid);
+        LOGGER.d(String.format("Address %s: createServiceRecord with uuid %s", mAddress, uuid));
+        if (isUuidRegistered(uuid) || !checkChannelAvailability()) {
+            return null;
+        }
+        synchronized (mLock) {
+            // ensure uuid is not registered and there's available channel
+            if (isUuidRegistered(uuid) || !checkChannelAvailability()) {
+                return null;
+            }
+            Iterator<Integer> available = mAvailablePortPool.iterator();
+            int port = available.next();
+            mAvailablePortPool.remove(port);
+            mInUsePortPool.add(port);
+            ServiceRecord record = new ServiceRecord(mAddress, serviceName, port);
+            mServiceRecords.put(uuid, record);
+            mFdUuidMap.put(record.mServerSocketFd, uuid);
+            PageScanHandler.getInstance().addServerSocket(record.mServerSocketFd);
+            return record;
+        }
+    }
+
+    public void removeServiceRecord(UUID uuid) {
+        Preconditions.checkNotNull(uuid);
+        LOGGER.d(String.format("Address %s: removeServiceRecord with uuid %s", mAddress, uuid));
+        if (!isUuidRegistered(uuid)) {
+            return;
+        }
+        synchronized (mLock) {
+            if (!isUuidRegistered(uuid)) {
+                return;
+            }
+            ServiceRecord record = mServiceRecords.get(uuid);
+            mServiceRecords.remove(uuid);
+            mInUsePortPool.remove(record.mPort);
+            mAvailablePortPool.add(record.mPort);
+            mFdUuidMap.remove(record.mServerSocketFd);
+        }
+    }
+
+    public ServiceRecord lookupChannel(UUID uuid) {
+        ServiceRecord record = mServiceRecords.get(uuid);
+        if (record == null) {
+            LOGGER.e(String.format("Address %s: uuid %s not registered.", mAddress, uuid));
+        }
+        return record;
+    }
+
+    public UUID getUuid(FileDescriptor serverSocketFd) {
+        return mFdUuidMap.get(serverSocketFd);
+    }
+
+    private boolean isUuidRegistered(UUID uuid) {
+        if (mServiceRecords.containsKey(uuid)) {
+            LOGGER.d(String.format("Address %s: Uuid %s in use.", mAddress, uuid));
+            return true;
+        }
+        LOGGER.d(String.format("Address %s: Uuid %s not registered.", mAddress, uuid));
+        return false;
+    }
+
+    private boolean checkChannelAvailability() {
+        if (mAvailablePortPool.isEmpty()) {
+            LOGGER.e(String.format("Address %s: No available channel.", mAddress));
+            return false;
+        }
+        return true;
+    }
+
+    static class ServiceRecord {
+
+        final FileDescriptor mServerSocketFd;
+        final String mServiceName;
+        final int mPort;
+
+        ServiceRecord(String address, String serviceName, int port) {
+            mServerSocketFd = FileDescriptorFactory.getInstance().createFileDescriptor(address);
+            this.mServiceName = serviceName;
+            this.mPort = port;
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/BroadcastManager.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/BroadcastManager.java
new file mode 100644
index 0000000..0b309ae
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/BroadcastManager.java
@@ -0,0 +1,526 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.common;
+
+import android.content.BroadcastReceiver;
+import android.content.BroadcastReceiver.PendingResult;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build.VERSION;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Function;
+import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Manager for broadcasting of one virtual Device Shadower device.
+ *
+ * <p>Inspired by {@link ShadowApplication} and {@link LocalBroadcastManager}.
+ * <li>Broadcast permission is not supported until manifest is supported.
+ * <li>Send Broadcast is asynchronous.
+ */
+public class BroadcastManager {
+
+    private static final Logger LOGGER = Logger.create("BroadcastManager");
+
+    private static final Comparator<ReceiverRecord> RECEIVER_RECORD_COMPARATOR =
+            new Comparator<ReceiverRecord>() {
+                @Override
+                public int compare(ReceiverRecord o1, ReceiverRecord o2) {
+                    return o2.mIntentFilter.getPriority() - o1.mIntentFilter.getPriority();
+                }
+            };
+
+    private final Scheduler mScheduler;
+    private final Map<String, Intent> mStickyIntents;
+
+    @GuardedBy("mRegisteredReceivers")
+    private final Map<BroadcastReceiver, Set<String>> mRegisteredReceivers;
+
+    @GuardedBy("mRegisteredReceivers")
+    private final Map<String, List<ReceiverRecord>> mActions;
+
+    public BroadcastManager(Scheduler scheduler) {
+        this(
+                scheduler,
+                new HashMap<String, Intent>(),
+                new HashMap<BroadcastReceiver, Set<String>>(),
+                new HashMap<String, List<ReceiverRecord>>());
+    }
+
+    @VisibleForTesting
+    BroadcastManager(
+            Scheduler scheduler,
+            Map<String, Intent> stickyIntents,
+            Map<BroadcastReceiver, Set<String>> registeredReceivers,
+            Map<String, List<ReceiverRecord>> actions) {
+        this.mScheduler = scheduler;
+        this.mStickyIntents = stickyIntents;
+        this.mRegisteredReceivers = registeredReceivers;
+        this.mActions = actions;
+    }
+
+    /**
+     * Registers a {@link BroadcastReceiver} with given {@link Context}.
+     *
+     * @see Context#registerReceiver(BroadcastReceiver, IntentFilter, String, Handler)
+     */
+    @Nullable
+    public Intent registerReceiver(
+            @Nullable BroadcastReceiver receiver,
+            IntentFilter filter,
+            @Nullable String broadcastPermission,
+            @Nullable Handler handler,
+            Context context) {
+        // Ignore broadcastPermission before fully supporting manifest
+        Preconditions.checkNotNull(filter);
+        Preconditions.checkNotNull(context);
+        if (receiver != null) {
+            synchronized (mRegisteredReceivers) {
+                ReceiverRecord receiverRecord = new ReceiverRecord(receiver, filter, context,
+                        handler);
+                Set<String> actionSet = mRegisteredReceivers.get(receiver);
+                if (actionSet == null) {
+                    actionSet = new HashSet<>();
+                    mRegisteredReceivers.put(receiver, actionSet);
+                }
+                for (int i = 0; i < filter.countActions(); i++) {
+                    String action = filter.getAction(i);
+                    actionSet.add(action);
+                    List<ReceiverRecord> receiverRecords = mActions.get(action);
+                    if (receiverRecords == null) {
+                        receiverRecords = new ArrayList<>();
+                        mActions.put(action, receiverRecords);
+                    }
+                    receiverRecords.add(receiverRecord);
+                }
+            }
+        }
+        return processStickyIntents(receiver, filter, context);
+    }
+
+    // Broadcast all sticky intents matching the given IntentFilter.
+    @SuppressWarnings("FutureReturnValueIgnored")
+    @Nullable
+    private Intent processStickyIntents(
+            @Nullable final BroadcastReceiver receiver,
+            IntentFilter intentFilter,
+            final Context context) {
+        Intent result = null;
+        final List<Intent> matchedIntents = new ArrayList<>();
+        for (Intent intent : mStickyIntents.values()) {
+            if (match(intentFilter, intent)) {
+                if (result == null) {
+                    result = intent;
+                }
+                if (receiver == null) {
+                    return result;
+                }
+                matchedIntents.add(intent);
+            }
+        }
+        if (!matchedIntents.isEmpty()) {
+            mScheduler.post(
+                    NamedRunnable.create(
+                            "Broadcast.processStickyIntents",
+                            () -> {
+                                for (Intent intent : matchedIntents) {
+                                    receiver.onReceive(context, intent);
+                                }
+                            }));
+        }
+        return result;
+    }
+
+    /**
+     * Unregisters a {@link BroadcastReceiver}.
+     *
+     * @see Context#unregisterReceiver(BroadcastReceiver)
+     */
+    public void unregisterReceiver(BroadcastReceiver broadcastReceiver) {
+        synchronized (mRegisteredReceivers) {
+            if (!mRegisteredReceivers.containsKey(broadcastReceiver)) {
+                LOGGER.w("Receiver not registered: " + broadcastReceiver);
+                return;
+            }
+            Set<String> actionSet = mRegisteredReceivers.remove(broadcastReceiver);
+            for (String action : actionSet) {
+                List<ReceiverRecord> receiverRecords = mActions.get(action);
+                Iterator<ReceiverRecord> iterator = receiverRecords.iterator();
+                while (iterator.hasNext()) {
+                    if (iterator.next().mBroadcastReceiver == broadcastReceiver) {
+                        iterator.remove();
+                    }
+                }
+                if (receiverRecords.isEmpty()) {
+                    mActions.remove(action);
+                }
+            }
+        }
+    }
+
+    /**
+     * Sends sticky broadcast with given {@link Intent}. This call is asynchronous.
+     *
+     * @see Context#sendStickyBroadcast(Intent)
+     */
+    public void sendStickyBroadcast(Intent intent) {
+        mStickyIntents.put(intent.getAction(), intent);
+        sendBroadcast(intent, null /* broadcastPermission */);
+    }
+
+    /**
+     * Sends broadcast with given {@link Intent}. Receiver permission is not supported. This call is
+     * asynchronous.
+     *
+     * @see Context#sendBroadcast(Intent, String)
+     */
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void sendBroadcast(final Intent intent, @Nullable String receiverPermission) {
+        // Ignore permission matching before fully supporting manifest
+        final List<ReceiverRecord> receivers =
+                getMatchingReceivers(intent, false /* isOrdered */);
+        if (receivers.isEmpty()) {
+            return;
+        }
+        mScheduler.post(
+                NamedRunnable.create(
+                        "Broadcast.sendBroadcast",
+                        () -> {
+                            for (ReceiverRecord receiverRecord : receivers) {
+                                // Hacky: Call the shadow method, otherwise abort() NPEs after
+                                // calling onReceive().
+                                // TODO(b/200231384): Sending these, via context.sendBroadcast(),
+                                //  won't NPE...but it may not be possible on each simulated
+                                //  "device"'s main thread. Check if possible.
+                                BroadcastReceiver broadcastReceiver =
+                                        receiverRecord.mBroadcastReceiver;
+                                Shadows.shadowOf(broadcastReceiver)
+                                        .onReceive(receiverRecord.mContext, intent, /*abort=*/
+                                                new AtomicBoolean(false));
+                            }
+                        }));
+    }
+
+    /**
+     * Sends ordered broadcast with given {@link Intent}. Receiver permission is not supported. This
+     * call is asynchronous.
+     *
+     * @see Context#sendOrderedBroadcast(Intent, String)
+     */
+    public void sendOrderedBroadcast(Intent intent, @Nullable String receiverPermission) {
+        sendOrderedBroadcast(
+                intent,
+                receiverPermission,
+                null /* resultReceiver */,
+                null /* handler */,
+                0 /* initialCode */,
+                null /* initialData */,
+                null /* initialExtras */,
+                null /* context */);
+    }
+
+    /**
+     * Sends ordered broadcast with given {@link Intent} and result {@link BroadcastReceiver}.
+     * Receiver permission is not supported. This call is asynchronous.
+     *
+     * @see Context#sendOrderedBroadcast(Intent, String, BroadcastReceiver, Handler, int, String,
+     * Bundle)
+     */
+    @SuppressWarnings("FutureReturnValueIgnored")
+    public void sendOrderedBroadcast(
+            final Intent intent,
+            @Nullable String receiverPermission,
+            @Nullable BroadcastReceiver resultReceiver,
+            @Nullable Handler handler,
+            int initialCode,
+            @Nullable String initialData,
+            @Nullable Bundle initialExtras,
+            @Nullable Context context) {
+        // Ignore permission matching before fully supporting manifest
+        final List<ReceiverRecord> receivers =
+                getMatchingReceivers(intent, true /* isOrdered */);
+        if (receivers.isEmpty()) {
+            return;
+        }
+        if (resultReceiver != null) {
+            receivers.add(
+                    new ReceiverRecord(
+                            resultReceiver, null /* intentFilter */, context, handler));
+        }
+        mScheduler.post(
+                NamedRunnable.create(
+                        "Broadcast.sendOrderedBroadcast",
+                        () -> {
+                            postOrderedIntent(
+                                    receivers,
+                                    intent,
+                                    0 /* initialCode */,
+                                    null /* initialData */,
+                                    null /* initialExtras */);
+                        }));
+    }
+
+    @VisibleForTesting
+    void postOrderedIntent(
+            List<ReceiverRecord> receivers,
+            final Intent intent,
+            int initialCode,
+            @Nullable String initialData,
+            @Nullable Bundle initialExtras) {
+        final AtomicBoolean abort = new AtomicBoolean(false);
+        ListenableFuture<BroadcastResult> resultFuture =
+                Futures.immediateFuture(
+                        new BroadcastResult(initialCode, initialData, initialExtras));
+
+        for (ReceiverRecord receiverRecord : receivers) {
+            final BroadcastReceiver receiver = receiverRecord.mBroadcastReceiver;
+            final Context context = receiverRecord.mContext;
+            resultFuture =
+                    Futures.transformAsync(
+                            resultFuture,
+                            new AsyncFunction<BroadcastResult, BroadcastResult>() {
+                                @Override
+                                public ListenableFuture<BroadcastResult> apply(
+                                        BroadcastResult input) {
+                                    PendingResult result = newPendingResult(
+                                            input.mCode, input.mData, input.mExtras,
+                                            true /* isOrdered */);
+                                    ReflectionHelpers.callInstanceMethod(
+                                            receiver, "setPendingResult",
+                                            ClassParameter.from(PendingResult.class, result));
+                                    Shadows.shadowOf(receiver).onReceive(context, intent, abort);
+                                    return BroadcastResult.transform(result);
+                                }
+                            },
+                            MoreExecutors.directExecutor());
+        }
+        Futures.addCallback(
+                resultFuture,
+                new FutureCallback<BroadcastResult>() {
+                    @Override
+                    public void onSuccess(BroadcastResult result) {
+                        return;
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                        throw new RuntimeException(t);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private List<ReceiverRecord> getMatchingReceivers(Intent intent, boolean isOrdered) {
+        synchronized (mRegisteredReceivers) {
+            List<ReceiverRecord> result = new ArrayList<>();
+            if (!mActions.containsKey(intent.getAction())) {
+                return result;
+            }
+            Iterator<ReceiverRecord> iterator = mActions.get(intent.getAction()).iterator();
+            while (iterator.hasNext()) {
+                ReceiverRecord next = iterator.next();
+                if (match(next.mIntentFilter, intent)) {
+                    result.add(next);
+                }
+            }
+            if (isOrdered) {
+                Collections.sort(result, RECEIVER_RECORD_COMPARATOR);
+            }
+            return result;
+        }
+    }
+
+    private boolean match(IntentFilter intentFilter, Intent intent) {
+        // Action test
+        if (!intentFilter.matchAction(intent.getAction())) {
+            return false;
+        }
+        // Category test
+        if (intentFilter.matchCategories(intent.getCategories()) != null) {
+            return false;
+        }
+        // Data test
+        int matchResult =
+                intentFilter.matchData(intent.getType(), intent.getScheme(), intent.getData());
+        return matchResult != IntentFilter.NO_MATCH_TYPE
+                && matchResult != IntentFilter.NO_MATCH_DATA;
+    }
+
+    private static PendingResult newPendingResult(
+            int resultCode, String resultData, Bundle resultExtras, boolean isOrdered) {
+        ClassParameter<?>[] parameters;
+        // PendingResult constructor takes different parameters in different SDK levels.
+        if (VERSION.SDK_INT < 17) {
+            parameters =
+                    ClassParameter.fromComponentLists(
+                            new Class<?>[]{
+                                    int.class,
+                                    String.class,
+                                    Bundle.class,
+                                    int.class,
+                                    boolean.class,
+                                    boolean.class,
+                                    IBinder.class
+                            },
+                            new Object[]{
+                                    resultCode,
+                                    resultData,
+                                    resultExtras,
+                                    0 /* type */,
+                                    isOrdered,
+                                    false /* sticky */,
+                                    null /* IBinder */
+                            });
+        } else if (VERSION.SDK_INT < 23) {
+            parameters =
+                    ClassParameter.fromComponentLists(
+                            new Class<?>[]{
+                                    int.class,
+                                    String.class,
+                                    Bundle.class,
+                                    int.class,
+                                    boolean.class,
+                                    boolean.class,
+                                    IBinder.class,
+                                    int.class
+                            },
+                            new Object[]{
+                                    resultCode,
+                                    resultData,
+                                    resultExtras,
+                                    0 /* type */,
+                                    isOrdered,
+                                    false /* sticky */,
+                                    null /* IBinder */,
+                                    0 /* userId */
+                            });
+        } else {
+            parameters =
+                    ClassParameter.fromComponentLists(
+                            new Class<?>[]{
+                                    int.class,
+                                    String.class,
+                                    Bundle.class,
+                                    int.class,
+                                    boolean.class,
+                                    boolean.class,
+                                    IBinder.class,
+                                    int.class,
+                                    int.class
+                            },
+                            new Object[]{
+                                    resultCode,
+                                    resultData,
+                                    resultExtras,
+                                    0 /* type */,
+                                    isOrdered,
+                                    false /* sticky */,
+                                    null /* IBinder */,
+                                    0 /* userId */,
+                                    0 /* flags */
+                            });
+        }
+        return ReflectionHelpers.callConstructor(PendingResult.class, parameters);
+    }
+
+    /**
+     * Holder of broadcast result from previous receiver.
+     */
+    private static final class BroadcastResult {
+
+        private final int mCode;
+        private final String mData;
+        private final Bundle mExtras;
+
+        BroadcastResult(int code, String data, Bundle extras) {
+            this.mCode = code;
+            this.mData = data;
+            this.mExtras = extras;
+        }
+
+        private static ListenableFuture<BroadcastResult> transform(PendingResult result) {
+            return Futures.transform(
+                    Shadows.shadowOf(result).getFuture(),
+                    new Function<PendingResult, BroadcastResult>() {
+                        @Override
+                        public BroadcastResult apply(PendingResult input) {
+                            return new BroadcastResult(
+                                    input.getResultCode(), input.getResultData(),
+                                    input.getResultExtras(false));
+                        }
+                    },
+                    MoreExecutors.directExecutor());
+        }
+    }
+
+    /**
+     * Information of a registered BroadcastReceiver.
+     */
+    @VisibleForTesting
+    static final class ReceiverRecord {
+
+        final BroadcastReceiver mBroadcastReceiver;
+        final IntentFilter mIntentFilter;
+        final Context mContext;
+        final Handler mHandler;
+
+        @VisibleForTesting
+        ReceiverRecord(
+                BroadcastReceiver broadcastReceiver,
+                IntentFilter intentFilter,
+                Context context,
+                Handler handler) {
+            this.mBroadcastReceiver = broadcastReceiver;
+            this.mIntentFilter = intentFilter;
+            this.mContext = context;
+            this.mHandler = handler;
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/ContentDatabase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/ContentDatabase.java
new file mode 100644
index 0000000..1f4d778
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/ContentDatabase.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.common;
+
+import android.database.Cursor;
+
+import org.robolectric.fakes.RoboCursor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Simulate Sqlite database for Android content provider.
+ */
+public class ContentDatabase {
+
+    private final List<String> mColumnNames;
+    private final List<List<Object>> mData;
+
+    public ContentDatabase(String... names) {
+        mColumnNames = Arrays.asList(names);
+        mData = new ArrayList<>();
+    }
+
+    public void addData(Object... items) {
+        mData.add(Arrays.asList(items));
+    }
+
+    public Cursor getCursor() {
+        RoboCursor cursor = new RoboCursor();
+        cursor.setColumnNames(mColumnNames);
+        Object[][] dataArr = new Object[mData.size()][mColumnNames.size()];
+        for (int i = 0; i < mData.size(); i++) {
+            dataArr[i] = new Object[mColumnNames.size()];
+            mData.get(i).toArray(dataArr[i]);
+        }
+        cursor.setResults(dataArr);
+        return cursor;
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Interrupter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Interrupter.java
new file mode 100644
index 0000000..66e9cb0
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Interrupter.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.common;
+
+import com.android.libraries.testing.deviceshadower.Enums;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Interrupter sets and checks interruptible point, and interrupt operation by throwing
+ * IOException.
+ */
+public class Interrupter {
+
+    private final InheritableThreadLocal<Integer> mCurrentIdentifier;
+    private int mInterruptIdentifier;
+
+    private final Set<Enums.Operation> mInterruptOperations = new HashSet<>();
+
+    public Interrupter() {
+        mCurrentIdentifier = new InheritableThreadLocal<Integer>() {
+            @Override
+            protected Integer initialValue() {
+                return -1;
+            }
+        };
+    }
+
+    public void checkInterrupt() throws IOException {
+        if (mCurrentIdentifier.get() == mInterruptIdentifier) {
+            throw new IOException(
+                    "Bluetooth interrupted at identifier: " + mCurrentIdentifier.get());
+        }
+    }
+
+    public void setInterruptible(int identifier) {
+        mCurrentIdentifier.set(identifier);
+    }
+
+    public void interrupt(int identifier) {
+        mInterruptIdentifier = identifier;
+    }
+
+    public void addInterruptOperation(Enums.Operation operation) {
+        mInterruptOperations.add(operation);
+    }
+
+    public boolean shouldInterrupt(Enums.Operation operation) {
+        return mInterruptOperations.contains(operation);
+    }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/NamedRunnable.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/NamedRunnable.java
new file mode 100644
index 0000000..4e84d71
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/NamedRunnable.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.common;
+
+/**
+ * Runnable with a name defined.
+ */
+public abstract class NamedRunnable implements Runnable {
+
+    private final String mName;
+
+    private NamedRunnable(String name) {
+        this.mName = name;
+    }
+
+    public static NamedRunnable create(String name, Runnable runnable) {
+        return new NamedRunnable(name) {
+            @Override
+            public void run() {
+                runnable.run();
+            }
+        };
+    }
+
+    @Override
+    public String toString() {
+        return mName;
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Scheduler.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Scheduler.java
new file mode 100644
index 0000000..96e9b15
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Scheduler.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.common;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Scheduler to post runnables to a single thread.
+ */
+public class Scheduler {
+
+    private static final Logger LOGGER = Logger.create("Scheduler");
+
+    @GuardedBy("Scheduler.class")
+    private static int sTotalRunnables = 0;
+
+    private static CountDownLatch sCompleteLatch;
+
+    public Scheduler() {
+        this(null);
+    }
+
+    public Scheduler(String name) {
+        mExecutor =
+                Executors.newSingleThreadExecutor(
+                        r -> {
+                            Thread thread = Executors.defaultThreadFactory().newThread(r);
+                            if (name != null) {
+                                thread.setName(name);
+                            }
+                            return thread;
+                        });
+    }
+
+    public static boolean await(long timeoutMillis) throws InterruptedException {
+
+        synchronized (Scheduler.class) {
+            if (isComplete()) {
+                return true;
+            }
+            if (sCompleteLatch == null) {
+                sCompleteLatch = new CountDownLatch(1);
+            }
+        }
+
+        // TODO(b/200231384): solve potential NPE caused by race condition.
+        boolean result = sCompleteLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
+        synchronized (Scheduler.class) {
+            sCompleteLatch = null;
+        }
+        return result;
+    }
+
+    private final ExecutorService mExecutor;
+
+    @GuardedBy("this")
+    private final List<ScheduledRunnable> mRunnables = new ArrayList<>();
+
+    @GuardedBy("this")
+    private long mCurrentTimeMillis = 0;
+
+    @GuardedBy("this")
+    private List<ScheduledRunnable> mRunningRunnables = new ArrayList<>();
+
+    /**
+     * Post a {@link NamedRunnable} to scheduler.
+     *
+     * <p>Return value can be ignored because exception will be handled by {@link
+     * DeviceShadowEnvironmentImpl#catchInternalException}.
+     */
+    // @CanIgnoreReturnValue
+    public synchronized Future<?> post(NamedRunnable r) {
+        synchronized (Scheduler.class) {
+            sTotalRunnables++;
+        }
+        advance(0);
+        return mExecutor.submit(new ScheduledRunnable(r, mCurrentTimeMillis).mRunnable);
+    }
+
+    public synchronized void post(NamedRunnable r, long delayMillis) {
+        synchronized (Scheduler.class) {
+            sTotalRunnables++;
+        }
+        addRunnables(new ScheduledRunnable(r, mCurrentTimeMillis + delayMillis));
+        advance(0);
+    }
+
+    public synchronized void shutdown() {
+        mExecutor.shutdown();
+    }
+
+    @VisibleForTesting
+    synchronized void advance(long durationMillis) {
+        mCurrentTimeMillis += durationMillis;
+        while (mRunnables.size() > 0) {
+            ScheduledRunnable r = mRunnables.get(0);
+            if (r.mTimeMillis <= mCurrentTimeMillis) {
+                mRunnables.remove(0);
+                mExecutor.execute(r.mRunnable);
+            } else {
+                break;
+            }
+        }
+    }
+
+    private synchronized void addRunnables(ScheduledRunnable r) {
+        int index = 0;
+        while (index < mRunnables.size() && mRunnables.get(index).mTimeMillis <= r.mTimeMillis) {
+            index++;
+        }
+        mRunnables.add(index, r);
+    }
+
+    @VisibleForTesting
+    static synchronized boolean isComplete() {
+        return sTotalRunnables == 0;
+    }
+
+    // Can only be called by DeviceShadowEnvironmentImpl when reset.
+    public static synchronized void clear() {
+        sTotalRunnables = 0;
+    }
+
+    class ScheduledRunnable {
+
+        final NamedRunnable mRunnable;
+        final long mTimeMillis;
+
+        ScheduledRunnable(final NamedRunnable r, long timeMillis) {
+            this.mTimeMillis = timeMillis;
+            this.mRunnable =
+                    NamedRunnable.create(
+                            r.toString(),
+                            () -> {
+                                synchronized (Scheduler.this) {
+                                    Scheduler.this.mRunningRunnables.add(ScheduledRunnable.this);
+                                }
+
+                                try {
+                                    r.run();
+                                } catch (Exception e) {
+                                    LOGGER.e("Error in scheduler runnable " + r, e);
+                                    DeviceShadowEnvironmentImpl.catchInternalException(e);
+                                }
+
+                                synchronized (Scheduler.this) {
+                                    // Remove the last one.
+                                    Scheduler.this.mRunningRunnables.remove(
+                                            Scheduler.this.mRunningRunnables.size() - 1);
+                                }
+
+                                // If this is last runnable,
+                                // When this section runs before await:
+                                //   totalRunnable will be 0, await will return directly.
+                                // When this section runs after await:
+                                //   latch will not be null, count down will terminate await.
+
+                                // TODO(b/200231384): when there are two threads running at same
+                                // time, there will be a case when totalRunnable is 0, but another
+                                // thread pending to acquire Scheduler.class lock to post a
+                                // runnable. Hence, await here might not be correct in this case.
+                                synchronized (Scheduler.class) {
+                                    sTotalRunnables--;
+                                    if (isComplete()) {
+                                        if (sCompleteLatch != null) {
+                                            sCompleteLatch.countDown();
+                                        }
+                                    }
+                                }
+                            });
+        }
+
+        @Override
+        public String toString() {
+            return mRunnable.toString();
+        }
+    }
+
+    @Override
+    public synchronized String toString() {
+        return String.format(
+                "\t%d scheduled runnables %s\n\t%d still running or aborted %s",
+                mRunnables.size(), mRunnables, mRunningRunnables.size(), mRunningRunnables);
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/INfcAdapterImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/INfcAdapterImpl.java
new file mode 100644
index 0000000..01dcac2
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/INfcAdapterImpl.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.nfc;
+
+import android.nfc.IAppCallback;
+import android.nfc.INfcAdapter;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+
+/**
+ * Implementation of INfcAdapter
+ */
+public class INfcAdapterImpl implements INfcAdapter {
+
+    public INfcAdapterImpl() {
+    }
+
+    @Override
+    public void setAppCallback(IAppCallback callback) {
+        DeviceShadowEnvironmentImpl.getLocalNfcletImpl().mAppCallback = callback;
+    }
+
+    @Override
+    public boolean enable() {
+        return DeviceShadowEnvironmentImpl.getLocalNfcletImpl().enable();
+    }
+
+    @Override
+    public boolean disable(boolean saveState) {
+        // We do not need to save state because test only run once.
+        return DeviceShadowEnvironmentImpl.getLocalNfcletImpl().disable();
+    }
+
+    @Override
+    public int getState() {
+        return DeviceShadowEnvironmentImpl.getLocalNfcletImpl().getState();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/NfcletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/NfcletImpl.java
new file mode 100644
index 0000000..137f6b8
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/NfcletImpl.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.nfc;
+
+import android.content.Intent;
+import android.nfc.BeamShareData;
+import android.nfc.IAppCallback;
+import android.nfc.NdefMessage;
+import android.nfc.NfcAdapter;
+
+import com.android.libraries.testing.deviceshadower.Enums.NfcOperation;
+import com.android.libraries.testing.deviceshadower.Nfclet;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.Interrupter;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Implementation of Nfclet.
+ */
+public class NfcletImpl implements Nfclet {
+
+    private static final Logger LOGGER = Logger.create("NfcletImpl");
+
+    IAppCallback mAppCallback;
+    private final Interrupter mInterrupter;
+
+    @GuardedBy("this")
+    private int mCurrentState;
+
+    public NfcletImpl() {
+        mInterrupter = new Interrupter();
+        mCurrentState = NfcAdapter.STATE_OFF;
+    }
+
+    public void onNear(NfcletImpl remote) {
+        if (remote.mAppCallback != null) {
+            LOGGER.v("NFC receiver get beam share data from remote");
+            BeamShareData data = remote.mAppCallback.createBeamShareData();
+            DeviceShadowEnvironmentImpl.getLocalDeviceletImpl().getBroadcastManager()
+                    .sendBroadcast(createNdefDiscoveredIntent(data), null);
+        }
+        if (mAppCallback != null) {
+            LOGGER.v("NFC sender onNdefPushComplete");
+            mAppCallback.onNdefPushComplete();
+        }
+    }
+
+    public synchronized int getState() {
+        return mCurrentState;
+    }
+
+    public boolean enable() {
+        if (shouldInterrupt(NfcOperation.ENABLE)) {
+            return false;
+        }
+        LOGGER.v("Enable NFC Adapter");
+        updateState(NfcAdapter.STATE_TURNING_ON);
+        updateState(NfcAdapter.STATE_ON);
+        return true;
+    }
+
+    public boolean disable() {
+        if (shouldInterrupt(NfcOperation.DISABLE)) {
+            return false;
+        }
+        LOGGER.v("Disable NFC Adapter");
+        updateState(NfcAdapter.STATE_TURNING_OFF);
+        updateState(NfcAdapter.STATE_OFF);
+        return true;
+    }
+
+    @Override
+    public synchronized Nfclet setInitialState(int state) {
+        mCurrentState = state;
+        return this;
+    }
+
+    @Override
+    public Nfclet setInterruptOperation(NfcOperation operation) {
+        mInterrupter.addInterruptOperation(operation);
+        return this;
+    }
+
+    public boolean shouldInterrupt(NfcOperation operation) {
+        return mInterrupter.shouldInterrupt(operation);
+    }
+
+    private synchronized void updateState(int state) {
+        if (mCurrentState != state) {
+            mCurrentState = state;
+            DeviceShadowEnvironmentImpl.getLocalDeviceletImpl().getBroadcastManager()
+                    .sendBroadcast(createAdapterStateChangedIntent(state), null);
+        }
+    }
+
+    private Intent createAdapterStateChangedIntent(int state) {
+        Intent intent = new Intent(NfcAdapter.ACTION_ADAPTER_STATE_CHANGED);
+        intent.putExtra(NfcAdapter.EXTRA_ADAPTER_STATE, state);
+        return intent;
+    }
+
+    private Intent createNdefDiscoveredIntent(BeamShareData data) {
+        Intent intent = new Intent();
+        intent.setAction(NfcAdapter.ACTION_NDEF_DISCOVERED);
+        intent.putExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, new NdefMessage[]{data.ndefMessage});
+        // TODO(b/200231384): uncomment when uri and mime type implemented.
+        // ndefUri = message.getRecords()[0].toUri();
+        // ndefMimeType = message.getRecords()[0].toMimeType();
+        return intent;
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsContentProvider.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsContentProvider.java
new file mode 100644
index 0000000..6bc535b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsContentProvider.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.sms;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+
+/**
+ * Content provider for SMS query.
+ */
+public class SmsContentProvider extends ContentProvider {
+
+    public SmsContentProvider() {
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean onCreate() {
+        return true;
+    }
+
+    @Override
+    public Cursor query(
+            Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        return DeviceShadowEnvironmentImpl.getLocalSmsletImpl().getCursor(uri);
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsletImpl.java
new file mode 100644
index 0000000..00a581e
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsletImpl.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.sms;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.Telephony;
+
+import com.android.libraries.testing.deviceshadower.Smslet;
+import com.android.libraries.testing.deviceshadower.internal.common.ContentDatabase;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Implementation of SMS functionality.
+ */
+public class SmsletImpl implements Smslet {
+
+    private final Map<Uri, ContentDatabase> mUriToDataMap;
+
+    public SmsletImpl() {
+        mUriToDataMap = new HashMap<>();
+        mUriToDataMap.put(
+                Telephony.Sms.Inbox.CONTENT_URI, new ContentDatabase(Telephony.Sms.Inbox.BODY));
+        mUriToDataMap.put(Telephony.Sms.Sent.CONTENT_URI,
+                new ContentDatabase(Telephony.Sms.Inbox.BODY));
+        // TODO(b/200231384): implement Outbox, Intents, Conversations.
+    }
+
+    @Override
+    public Smslet addSms(Uri contentUri, String body) {
+        mUriToDataMap.get(contentUri).addData(body);
+        return this;
+    }
+
+    public Cursor getCursor(Uri uri) {
+        return mUriToDataMap.get(uri).getCursor();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/GattHelper.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/GattHelper.java
new file mode 100644
index 0000000..f45b125
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/GattHelper.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.utils;
+
+import android.bluetooth.le.AdvertiseData;
+import android.os.ParcelUuid;
+import android.util.SparseArray;
+
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BluetoothConstants;
+
+import com.google.common.io.ByteArrayDataOutput;
+import com.google.common.io.ByteStreams;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.UUID;
+
+/**
+ * Helper class for Gatt functionality.
+ */
+public class GattHelper {
+
+    public static byte[] convertAdvertiseData(
+            AdvertiseData data, int txPowerLevel, String localName, boolean isConnectable) {
+        if (data == null) {
+            return new byte[0];
+        }
+        ByteArrayDataOutput result = ByteStreams.newDataOutput();
+        if (isConnectable) {
+            writeDataUnit(
+                    result,
+                    BluetoothConstants.DATA_TYPE_FLAGS,
+                    new byte[]{BluetoothConstants.FLAGS_IN_CONNECTABLE_PACKETS});
+        }
+        // tx power level is signed 8-bit int, range -100 to 20.
+        if (data.getIncludeTxPowerLevel()) {
+            writeDataUnit(
+                    result,
+                    BluetoothConstants.DATA_TYPE_TX_POWER_LEVEL,
+                    new byte[]{(byte) txPowerLevel});
+        }
+        // Local name
+        if (data.getIncludeDeviceName()) {
+            writeDataUnit(
+                    result,
+                    BluetoothConstants.DATA_TYPE_LOCAL_NAME_COMPLETE,
+                    localName.getBytes(Charset.defaultCharset()));
+        }
+        // Manufacturer data
+        SparseArray<byte[]> manufacturerData = data.getManufacturerSpecificData();
+        for (int i = 0; i < manufacturerData.size(); i++) {
+            int manufacturerId = manufacturerData.keyAt(i);
+            writeDataUnit(
+                    result,
+                    BluetoothConstants.DATA_TYPE_MANUFACTURER_SPECIFIC_DATA,
+                    parseManufacturerData(manufacturerId, manufacturerData.get(manufacturerId))
+            );
+        }
+        // Service data
+        Map<ParcelUuid, byte[]> serviceData = data.getServiceData();
+        for (Entry<ParcelUuid, byte[]> entry : serviceData.entrySet()) {
+            writeDataUnit(
+                    result,
+                    BluetoothConstants.DATA_TYPE_SERVICE_DATA,
+                    parseServiceData(entry.getKey().getUuid(), entry.getValue())
+            );
+        }
+        // Service UUID, 128-bit UUID in little endian
+        if (data.getServiceUuids() != null && !data.getServiceUuids().isEmpty()) {
+            ByteBuffer uuidBytes =
+                    ByteBuffer.allocate(data.getServiceUuids().size() * 16)
+                            .order(ByteOrder.LITTLE_ENDIAN);
+            for (ParcelUuid parcelUuid : data.getServiceUuids()) {
+                UUID uuid = parcelUuid.getUuid();
+                uuidBytes.putLong(uuid.getLeastSignificantBits())
+                        .putLong(uuid.getMostSignificantBits());
+            }
+            writeDataUnit(
+                    result,
+                    BluetoothConstants.DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE,
+                    uuidBytes.array()
+            );
+        }
+        return result.toByteArray();
+    }
+
+    private static byte[] parseServiceData(UUID uuid, byte[] serviceData) {
+        // First two bytes of the data are data UUID in little endian
+        int length = 2 + serviceData.length;
+        byte[] result = new byte[length];
+        // extract 16-bit UUID value
+        int uuidValue = (int) ((uuid.getMostSignificantBits() & 0x0000FFFF00000000L) >>> 32);
+        result[0] = (byte) (uuidValue & 0xFF);
+        result[1] = (byte) ((uuidValue >> 8) & 0xFF);
+        System.arraycopy(serviceData, 0, result, 2, serviceData.length);
+        return result;
+
+    }
+
+    private static byte[] parseManufacturerData(int manufacturerId, byte[] manufacturerData) {
+        // First two bytes are manufacturer id in little endian.
+        int length = 2 + manufacturerData.length;
+        byte[] result = new byte[length];
+        result[0] = (byte) (manufacturerId & 0xFF);
+        result[1] = (byte) ((manufacturerId >> 8) & 0xFF);
+        System.arraycopy(manufacturerData, 0, result, 2, manufacturerData.length);
+        return result;
+    }
+
+    private static void writeDataUnit(ByteArrayDataOutput output, int type, byte[] data) {
+        // Length includes the length of the field type, which is 1 byte.
+        int length = 1 + data.length;
+        // Length and type are unsigned 8-bit int. Assume the values are valid.
+        output.write(length);
+        output.write(type);
+        output.write(data);
+    }
+
+    private GattHelper() {
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/Logger.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/Logger.java
new file mode 100644
index 0000000..31f7202
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/Logger.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.utils;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+/**
+ * Logger class to provide formatted log for Device Shadower.
+ *
+ * <p>Log is formatted as "[TAG] [Keyword1, Keyword2 ...] Log Message Body".</p>
+ */
+public class Logger {
+
+    private static final String TAG = "DeviceShadower";
+
+    private final String mTag;
+    private final String mPrefix;
+
+    public Logger(String tag, String... keywords) {
+        mTag = tag;
+        mPrefix = buildPrefix(keywords);
+    }
+
+    public static Logger create(String... keywords) {
+        return new Logger(TAG, keywords);
+    }
+
+    private static String buildPrefix(String... keywords) {
+        if (keywords.length == 0) {
+            return "";
+        }
+        return String.format(" [%s] ", TextUtils.join(", ", keywords));
+    }
+
+    /**
+     * @see Log#e(String, String)
+     */
+    public void e(String msg) {
+        Log.e(mTag, format(msg));
+    }
+
+    /**
+     * @see Log#e(String, String, Throwable)
+     */
+    public void e(String msg, Throwable throwable) {
+        Log.e(mTag, format(msg), throwable);
+    }
+
+    /**
+     * @see Log#d(String, String)
+     */
+    public void d(String msg) {
+        Log.d(mTag, format(msg));
+    }
+
+    /**
+     * @see Log#d(String, String, Throwable)
+     */
+    public void d(String msg, Throwable throwable) {
+        Log.d(mTag, format(msg), throwable);
+    }
+
+    /**
+     * @see Log#i(String, String)
+     */
+    public void i(String msg) {
+        Log.i(mTag, format(msg));
+    }
+
+    /**
+     * @see Log#i(String, String, Throwable)
+     */
+    public void i(String msg, Throwable throwable) {
+        Log.i(mTag, format(msg), throwable);
+    }
+
+    /**
+     * @see Log#v(String, String)
+     */
+    public void v(String msg) {
+        Log.v(mTag, format(msg));
+    }
+
+    /**
+     * @see Log#v(String, String, Throwable)
+     */
+    public void v(String msg, Throwable throwable) {
+        Log.v(mTag, format(msg), throwable);
+    }
+
+    /**
+     * @see Log#w(String, String)
+     */
+    public void w(String msg) {
+        Log.w(mTag, format(msg));
+    }
+
+    /**
+     * @see Log#w(String, Throwable)
+     */
+    public void w(Throwable throwable) {
+        Log.w(mTag, null, throwable);
+    }
+
+    /**
+     * @see Log#w(String, String, Throwable)
+     */
+    public void w(String msg, Throwable throwable) {
+        Log.w(mTag, format(msg), throwable);
+    }
+
+    /**
+     * @see Log#wtf(String, String)
+     */
+    public void wtf(String msg) {
+        Log.wtf(mTag, format(msg));
+    }
+
+    /**
+     * @see Log#wtf(String, String, Throwable)
+     */
+    public void wtf(String msg, Throwable throwable) {
+        Log.wtf(mTag, format(msg), throwable);
+    }
+
+    /**
+     * @see Log#isLoggable(String, int)
+     */
+    public boolean isLoggable(int level) {
+        return Log.isLoggable(mTag, level);
+    }
+
+    /**
+     * @see Log#println(int, String, String)
+     */
+    public int println(int priority, String msg) {
+        return Log.println(priority, mTag, format(msg));
+    }
+
+    private String format(String msg) {
+        return mPrefix + msg;
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/MacAddressGenerator.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/MacAddressGenerator.java
new file mode 100644
index 0000000..f8d3193
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/MacAddressGenerator.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.internal.utils;
+
+import android.bluetooth.BluetoothAdapter;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Locale;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * A class which generates and converts valid Bluetooth MAC addresses.
+ */
+public class MacAddressGenerator {
+
+    @GuardedBy("MacAddressGenerator.class")
+    private static MacAddressGenerator sInstance = new MacAddressGenerator();
+
+    @VisibleForTesting
+    public static synchronized void setInstanceForTest(MacAddressGenerator generator) {
+        sInstance = generator;
+    }
+
+    public static synchronized MacAddressGenerator get() {
+        return sInstance;
+    }
+
+    private long mLastAddress = 0x0L;
+
+    private MacAddressGenerator() {
+    }
+
+    public String generateMacAddress() {
+        byte[] bytes = generateMacAddressBytes();
+        return convertByteMacAddress(bytes);
+    }
+
+    public byte[] generateMacAddressBytes() {
+        long addr = mLastAddress++;
+        byte[] bytes = new byte[6];
+        for (int i = 5; i >= 0; i--) {
+            bytes[i] = (byte) (addr & 0xFF);
+            addr = addr >> 8;
+        }
+        return bytes;
+    }
+
+    public static byte[] convertStringMacAddress(String address) {
+        if (!BluetoothAdapter.checkBluetoothAddress(address)) {
+            throw new IllegalArgumentException("Not a valid bluetooth mac hex string: " + address);
+        }
+        byte[] bytes = new byte[6];
+        String[] macValues = address.split(":");
+        for (int i = 0; i < bytes.length; i++) {
+            bytes[i] = Integer.decode("0x" + macValues[i]).byteValue();
+        }
+        return bytes;
+    }
+
+    public static String convertByteMacAddress(byte[] address) {
+        if (address == null || address.length != 6) {
+            throw new IllegalArgumentException("Bluetooth address must have 6 bytes");
+        }
+        return String.format(Locale.US, "%02X:%02X:%02X:%02X:%02X:%02X",
+                address[0], address[1], address[2], address[3], address[4], address[5]);
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothA2dp.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothA2dp.java
new file mode 100644
index 0000000..344103b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothA2dp.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import static com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl.getBlueletImpl;
+import static com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl.getLocalBlueletImpl;
+
+import android.bluetooth.BluetoothA2dp;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothProfile.ServiceListener;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Shadow of the Bluetooth A2DP service.
+ */
+@Implements(BluetoothA2dp.class)
+public class ShadowBluetoothA2dp {
+
+    /**
+     * Hidden in {@link BluetoothProfile}.
+     */
+    public static final int A2DP_SINK = 11;
+
+    private final Map<BluetoothDevice, Integer> mDeviceToConnectionState = new HashMap<>();
+    private Context mContext;
+    @RealObject
+    private BluetoothA2dp mRealObject;
+
+    public void __constructor__(Context context, ServiceListener l) {
+        this.mContext = context;
+        l.onServiceConnected(BluetoothProfile.A2DP, mRealObject);
+    }
+
+    @Implementation
+    public List<BluetoothDevice> getConnectedDevices() {
+        List<BluetoothDevice> result = new ArrayList<>();
+        for (BluetoothDevice device : mDeviceToConnectionState.keySet()) {
+            if (getConnectionState(device) == BluetoothProfile.STATE_CONNECTED) {
+                result.add(device);
+            }
+        }
+        return result;
+    }
+
+    @Implementation
+    public int getConnectionState(BluetoothDevice device) {
+        return mDeviceToConnectionState.containsKey(device)
+                ? mDeviceToConnectionState.get(device)
+                : BluetoothProfile.STATE_DISCONNECTED;
+    }
+
+    @Implementation
+    public boolean connect(BluetoothDevice device) {
+        setConnectionState(BluetoothProfile.STATE_CONNECTING, device);
+        // Only successfully connect if the device is in the environment (i.e. nearby) and accepts
+        // connections.
+        BlueletImpl blueLet = getBlueletImpl(device.getAddress());
+        if (blueLet != null && !blueLet.getRefuseConnections()) {
+            setConnectionState(BluetoothProfile.STATE_CONNECTED, device);
+        } else {
+            // If the device isn't in the environment, still return true (no immediate failure, i.e.
+            // we're trying to connect) but send CONNECTING -> DISCONNECTED (like the OS does).
+            setConnectionState(BluetoothProfile.STATE_DISCONNECTED, device);
+        }
+        return true;
+    }
+
+    @Implementation
+    public void close() {
+    }
+
+    private void setConnectionState(int state, BluetoothDevice device) {
+        int previousState = getConnectionState(device);
+        mDeviceToConnectionState.put(device, state);
+
+        getLocalBlueletImpl()
+                .setProfileConnectionState(BluetoothProfile.A2DP, state, device.getAddress());
+        BlueletImpl remoteDevice = getBlueletImpl(device.getAddress());
+        if (remoteDevice != null) {
+            remoteDevice.setProfileConnectionState(A2DP_SINK, state, getLocalBlueletImpl().address);
+        }
+
+        mContext.sendBroadcast(
+                new Intent(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
+                        .putExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, previousState)
+                        .putExtra(BluetoothProfile.EXTRA_STATE, state)
+                        .putExtra(BluetoothDevice.EXTRA_DEVICE, device));
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothAdapter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothAdapter.java
new file mode 100644
index 0000000..394afbc
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothAdapter.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.AttributionSource;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.MacAddressGenerator;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/**
+ * Shadow of {@link BluetoothAdapter} to be used with Device Shadower in Robolectric test.
+ */
+@Implements(BluetoothAdapter.class)
+public class ShadowBluetoothAdapter {
+
+    @RealObject
+    BluetoothAdapter mRealAdapter;
+
+    public ShadowBluetoothAdapter() {
+    }
+
+    @Implementation
+    public static synchronized BluetoothAdapter getDefaultAdapter() {
+        // Add a device and set local devicelet in case no local bluelet set
+        if (!DeviceShadowEnvironmentImpl.hasLocalDeviceletImpl()) {
+            String address = MacAddressGenerator.get().generateMacAddress();
+            DeviceShadowEnvironmentImpl.addDevice(address);
+            DeviceShadowEnvironmentImpl.setLocalDevice(address);
+        }
+        BlueletImpl localBluelet = DeviceShadowEnvironmentImpl.getLocalBlueletImpl();
+        return localBluelet.getAdapter();
+    }
+
+    @Implementation
+    public static BluetoothAdapter createAdapter(AttributionSource attributionSource) {
+        // Add a device and set local devicelet in case no local bluelet set
+        if (!DeviceShadowEnvironmentImpl.hasLocalDeviceletImpl()) {
+            String address = MacAddressGenerator.get().generateMacAddress();
+            DeviceShadowEnvironmentImpl.addDevice(address);
+            DeviceShadowEnvironmentImpl.setLocalDevice(address);
+        }
+        BlueletImpl localBluelet = DeviceShadowEnvironmentImpl.getLocalBlueletImpl();
+        return localBluelet.getAdapter();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothDevice.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothDevice.java
new file mode 100644
index 0000000..247f46e
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothDevice.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.IBluetoothImpl;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Placeholder for BluetoothDevice improvements
+ */
+@Implements(BluetoothDevice.class)
+public class ShadowBluetoothDevice {
+
+    @RealObject
+    private BluetoothDevice mBluetoothDevice;
+    private static final Map<String, Integer> sBondTransport = new HashMap<>();
+    private static Map<String, Boolean> sPairingConfirmation = new HashMap<>();
+
+    public ShadowBluetoothDevice() {
+    }
+
+    @Implementation
+    public boolean setPasskey(int passkey) {
+        return new IBluetoothImpl().setPasskey(mBluetoothDevice, passkey);
+    }
+
+    @Implementation
+    public boolean createBond(int transport) {
+        sBondTransport.put(mBluetoothDevice.getAddress(), transport);
+        return Shadow.directlyOn(
+                mBluetoothDevice,
+                BluetoothDevice.class,
+                "createBond",
+                ClassParameter.from(int.class, transport));
+    }
+
+    public static int getBondTransport(String address) {
+        return sBondTransport.containsKey(address)
+                ? sBondTransport.get(address)
+                : BluetoothDevice.TRANSPORT_AUTO;
+    }
+
+    @Implementation
+    public boolean setPairingConfirmation(boolean confirm) {
+        sPairingConfirmation.put(mBluetoothDevice.getAddress(), confirm);
+        return Shadow.directlyOn(
+                mBluetoothDevice,
+                BluetoothDevice.class,
+                "setPairingConfirmation",
+                ClassParameter.from(boolean.class, confirm));
+    }
+
+    /**
+     * Gets the confirmation value previously set with a call to {@link
+     * BluetoothDevice#setPairingConfirmation(boolean)}. Default is false.
+     */
+    public static boolean getPairingConfirmation(String address) {
+        return sPairingConfirmation.containsKey(address) && sPairingConfirmation.get(address);
+    }
+
+    /**
+     * Resets the confirmation values.
+     */
+    public static void resetPairingConfirmation() {
+        sPairingConfirmation = new HashMap<>();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothLeScanner.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothLeScanner.java
new file mode 100644
index 0000000..1f7da14
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothLeScanner.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.le.BluetoothLeScanner;
+
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow of {@link BluetoothLeScanner} to be used with Device Shadower in Robolectric test.
+ */
+@Implements(BluetoothLeScanner.class)
+public class ShadowBluetoothLeScanner {
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothServerSocket.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothServerSocket.java
new file mode 100644
index 0000000..bffcf32
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothServerSocket.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.net.LocalSocket;
+import android.os.ParcelFileDescriptor;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+
+/**
+ * Placeholder for BluetoothServerSocket updates
+ */
+@Implements(BluetoothServerSocket.class)
+public class ShadowBluetoothServerSocket {
+
+    @RealObject
+    BluetoothServerSocket mRealServerSocket;
+
+    public ShadowBluetoothServerSocket() {
+    }
+
+    @Implementation
+    public BluetoothSocket accept(int timeout) throws IOException {
+        FileDescriptor serverSocketFd = getServerSocketFileDescriptor();
+        if (serverSocketFd == null) {
+            throw new IOException("socket is closed.");
+        }
+        RfcommDelegate local = getLocalRfcommDelegate();
+        local.checkInterrupt();
+        FileDescriptor clientFd = local.processNextConnectionRequest(serverSocketFd);
+        // configure the LocalSocket of the BluetoothServerSocket
+        BluetoothSocket internalSocket = ReflectionHelpers.getField(mRealServerSocket, "mSocket");
+        ShadowLocalSocket internalLocalSocket = getLocalSocketShadow(internalSocket);
+        internalLocalSocket.setAncillaryFd(local.getServerFd(clientFd));
+
+        // call original method
+        BluetoothSocket socket = Shadow.directlyOn(mRealServerSocket, BluetoothServerSocket.class,
+                "accept", ClassParameter.from(int.class, timeout));
+
+        // setup local socket of the returned BluetoothSocket
+        String remoteAddress = socket.getRemoteDevice().getAddress();
+        ShadowLocalSocket shadowLocalSocket = getLocalSocketShadow(socket);
+        shadowLocalSocket.setRemoteAddress(remoteAddress);
+        // init connection to client
+        local.initiateConnectToClient(clientFd, getPort());
+        local.waitForConnectionEstablished(clientFd);
+        return socket;
+    }
+
+    @Implementation
+    public void close() throws IOException {
+        getLocalRfcommDelegate().closeServerSocket(getServerSocketFileDescriptor());
+        Shadow.directlyOn(mRealServerSocket, BluetoothServerSocket.class, "close");
+    }
+
+    @VisibleForTesting
+    FileDescriptor getServerSocketFileDescriptor() {
+        BluetoothSocket socket = ReflectionHelpers.getField(mRealServerSocket, "mSocket");
+        ParcelFileDescriptor pfd = ReflectionHelpers.getField(socket, "mPfd");
+        if (pfd == null) {
+            return null;
+        }
+        return pfd.getFileDescriptor();
+    }
+
+    @VisibleForTesting
+    int getPort() {
+        BluetoothSocket socket = ReflectionHelpers.getField(mRealServerSocket, "mSocket");
+        return ReflectionHelpers.getField(socket, "mPort");
+    }
+
+    private ShadowLocalSocket getLocalSocketShadow(BluetoothSocket socket) {
+        LocalSocket localSocket = ReflectionHelpers.getField(socket, "mSocket");
+        return (ShadowLocalSocket) Shadow.extract(localSocket);
+    }
+
+    private RfcommDelegate getLocalRfcommDelegate() {
+        return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().getRfcommDelegate();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothSocket.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothSocket.java
new file mode 100644
index 0000000..5d417cf
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothSocket.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothSocket;
+import android.os.ParcelFileDescriptor;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Shadow implementation of a Bluetooth Socket
+ */
+@Implements(BluetoothSocket.class)
+public class ShadowBluetoothSocket {
+
+    @RealObject
+    BluetoothSocket mRealSocket;
+
+    public ShadowBluetoothSocket() {
+    }
+
+    @Implementation
+    public void connect() throws IOException {
+        Shadow.directlyOn(mRealSocket, BluetoothSocket.class, "connect");
+
+        boolean isEncrypted = ReflectionHelpers.getField(mRealSocket, "mEncrypt");
+        FileDescriptor localFd =
+                ((ParcelFileDescriptor) ReflectionHelpers.getField(mRealSocket,
+                        "mPfd")).getFileDescriptor();
+        RfcommDelegate local = DeviceShadowEnvironmentImpl.getLocalBlueletImpl()
+                .getRfcommDelegate();
+        String remoteAddress = mRealSocket.getRemoteDevice().getAddress();
+        local.finishPendingConnection(remoteAddress, localFd, isEncrypted);
+
+        ShadowLocalSocket shadowLocalSocket = getLocalSocketShadow();
+        shadowLocalSocket.setRemoteAddress(remoteAddress);
+    }
+
+    @Implementation
+    public InputStream getInputStream() throws IOException {
+        ShadowLocalSocket socket = getLocalSocketShadow();
+        return socket.getInputStream();
+    }
+
+    @Implementation
+    public OutputStream getOutputStream() throws IOException {
+        ShadowLocalSocket socket = getLocalSocketShadow();
+        return socket.getOutputStream();
+    }
+
+    private ShadowLocalSocket getLocalSocketShadow() throws IOException {
+        try {
+            return (ShadowLocalSocket) Shadow.extract(
+                    ReflectionHelpers.getField(mRealSocket, "mSocket"));
+        } catch (NullPointerException e) {
+            throw new IOException(e);
+        }
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowLocalSocket.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowLocalSocket.java
new file mode 100644
index 0000000..5189330
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowLocalSocket.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.net.LocalSocket;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BluetoothConstants;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Shadow implementation of a LocalSocket to make bluetooth connections function.
+ */
+@Implements(LocalSocket.class)
+public class ShadowLocalSocket {
+
+    private String mRemoteAddress;
+    private FileDescriptor mFd;
+    private FileDescriptor mAncillaryFd;
+
+    public ShadowLocalSocket() {
+    }
+
+    public void __constructor__(FileDescriptor fd) {
+        this.mFd = fd;
+    }
+
+    @Implementation
+    public FileDescriptor[] getAncillaryFileDescriptors() throws IOException {
+        return new FileDescriptor[]{mAncillaryFd};
+    }
+
+    @Implementation
+    @SuppressWarnings("InputStreamSlowMultibyteRead")
+    public InputStream getInputStream() throws IOException {
+        final RfcommDelegate local = getLocalRfcommDelegate();
+        return new InputStream() {
+            @Override
+            public int read() throws IOException {
+                int res = local.read(mRemoteAddress, mFd);
+                if (res == BluetoothConstants.SOCKET_CLOSE) {
+                    throw new IOException("closed");
+                }
+                return res & 0xFF;
+            }
+        };
+    }
+
+    @Implementation
+    public OutputStream getOutputStream() throws IOException {
+        final RfcommDelegate local = getLocalRfcommDelegate();
+        return new OutputStream() {
+            @Override
+            public void write(int b) throws IOException {
+                local.write(mRemoteAddress, mFd, b);
+            }
+        };
+    }
+
+    @Implementation
+    public void setSoTimeout(int n) throws IOException {
+        // Nothing
+    }
+
+    @Implementation
+    public void shutdownInput() throws IOException {
+        getLocalRfcommDelegate().shutdownInput(mRemoteAddress, mFd);
+    }
+
+    @Implementation
+    public void shutdownOutput() throws IOException {
+        if (mRemoteAddress == null) {
+            return;
+        }
+        getLocalRfcommDelegate().shutdownOutput(mRemoteAddress, mFd);
+    }
+
+    void setAncillaryFd(FileDescriptor fd) {
+        mAncillaryFd = fd;
+    }
+
+    void setRemoteAddress(String address) {
+        mRemoteAddress = address;
+    }
+
+    @VisibleForTesting
+    void setFileDescriptorForTest(FileDescriptor fd) {
+        this.mFd = fd;
+    }
+
+    private RfcommDelegate getLocalRfcommDelegate() {
+        return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().getRfcommDelegate();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowParcelFileDescriptor.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowParcelFileDescriptor.java
new file mode 100644
index 0000000..585939b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowParcelFileDescriptor.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.os.ParcelFileDescriptor;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+
+/**
+ * Inert implementation of a ParcelFileDescriptor to make bluetooth connections function.
+ */
+@Implements(ParcelFileDescriptor.class)
+public class ShadowParcelFileDescriptor {
+
+    private FileDescriptor mFd;
+
+    public ShadowParcelFileDescriptor() {
+    }
+
+    public void __constructor__(FileDescriptor fd) {
+        this.mFd = fd;
+    }
+
+    @Implementation
+    public FileDescriptor getFileDescriptor() {
+        return mFd;
+    }
+
+    @Implementation
+    public void close() throws IOException {
+        // Nothing
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/common/DeviceShadowContextImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/common/DeviceShadowContextImpl.java
new file mode 100644
index 0000000..9bbcee7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/common/DeviceShadowContextImpl.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.common;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.DeviceletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.BroadcastManager;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadows.ShadowContextImpl;
+
+import javax.annotation.Nullable;
+
+/**
+ * Extends {@link ShadowContextImpl} to achieve automatic method redirection to correct virtual
+ * device.
+ *
+ * <p>Supports:
+ * <li>Broadcasting</li>
+ * Includes send regular, regular sticky, ordered broadcast, and register/unregister receiver.
+ * </p>
+ */
+@Implements(className = "android.app.ContextImpl")
+public class DeviceShadowContextImpl extends ShadowContextImpl {
+
+    private static final String TAG = "DeviceShadowContextImpl";
+
+    @RealObject
+    private Context mContextImpl;
+
+    @Override
+    @Implementation
+    @Nullable
+    public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+        if (receiver == null) {
+            return null;
+        }
+        BroadcastManager manager = getLocalBroadcastManager();
+        if (manager == null) {
+            Log.w(TAG, "Receiver registered before any devices added: " + receiver);
+            return null;
+        }
+        return manager.registerReceiver(
+                receiver, filter, null /* permission */, null /* handler */, mContextImpl);
+    }
+
+    @Override
+    @Implementation
+    @Nullable
+    public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter,
+            @Nullable String broadcastPermission, @Nullable Handler scheduler) {
+        return getLocalBroadcastManager().registerReceiver(
+                receiver, filter, broadcastPermission, scheduler, mContextImpl);
+    }
+
+    @Override
+    @Implementation
+    public void unregisterReceiver(BroadcastReceiver broadcastReceiver) {
+        getLocalBroadcastManager().unregisterReceiver(broadcastReceiver);
+    }
+
+    @Override
+    @Implementation
+    public void sendBroadcast(Intent intent) {
+        getLocalBroadcastManager().sendBroadcast(intent, null /* permission */);
+    }
+
+    @Override
+    @Implementation
+    public void sendBroadcast(Intent intent, @Nullable String receiverPermission) {
+        getLocalBroadcastManager().sendBroadcast(intent, receiverPermission);
+    }
+
+    @Override
+    @Implementation
+    public void sendOrderedBroadcast(Intent intent, @Nullable String receiverPermission) {
+        getLocalBroadcastManager().sendOrderedBroadcast(intent, receiverPermission);
+    }
+
+    @Override
+    @Implementation
+    public void sendOrderedBroadcast(Intent intent, @Nullable String receiverPermission,
+            @Nullable BroadcastReceiver resultReceiver, @Nullable Handler scheduler,
+            int initialCode, @Nullable String initialData, @Nullable Bundle initialExtras) {
+        getLocalBroadcastManager().sendOrderedBroadcast(intent, receiverPermission, resultReceiver,
+                scheduler, initialCode, initialData, initialExtras, mContextImpl);
+    }
+
+    @Override
+    @Implementation
+    public void sendStickyBroadcast(Intent intent) {
+        getLocalBroadcastManager().sendStickyBroadcast(intent);
+    }
+
+    private BroadcastManager getLocalBroadcastManager() {
+        DeviceletImpl devicelet = DeviceShadowEnvironmentImpl.getLocalDeviceletImpl();
+        if (devicelet == null) {
+            return null;
+        }
+        return devicelet.getBroadcastManager();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/nfc/ShadowNfcAdapter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/nfc/ShadowNfcAdapter.java
new file mode 100644
index 0000000..e7112fb
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/nfc/ShadowNfcAdapter.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.shadows.nfc;
+
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+
+import android.content.Context;
+import android.nfc.NfcAdapter;
+
+import com.android.libraries.testing.deviceshadower.Enums.NfcOperation;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.nfc.INfcAdapterImpl;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Shadow implementation of Nfc Adapter.
+ */
+@Implements(NfcAdapter.class)
+public class ShadowNfcAdapter {
+
+    @Implementation
+    public static NfcAdapter getDefaultAdapter(Context context) {
+        if (DeviceShadowEnvironmentImpl.getLocalNfcletImpl()
+                .shouldInterrupt(NfcOperation.GET_ADAPTER)) {
+            return null;
+        }
+        ReflectionHelpers.setStaticField(NfcAdapter.class, "sService", new INfcAdapterImpl());
+        return callConstructor(NfcAdapter.class, ClassParameter.from(Context.class, context));
+    }
+
+    // TODO(b/200231384): support state change.
+    public ShadowNfcAdapter() {
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BaseTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BaseTestCase.java
new file mode 100644
index 0000000..8a3c0e7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BaseTestCase.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.testcases;
+
+import android.app.Application;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowLocalSocket;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowParcelFileDescriptor;
+import com.android.libraries.testing.deviceshadower.shadows.common.DeviceShadowContextImpl;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.internal.AssumptionViolatedException;
+import org.junit.rules.TestWatcher;
+import org.junit.runner.Description;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/**
+ * Base class for all DeviceShadower client.
+ */
+@Config(
+        // sdk = 21,
+        shadows = {
+                DeviceShadowContextImpl.class,
+                ShadowParcelFileDescriptor.class,
+                ShadowLocalSocket.class
+        })
+public class BaseTestCase {
+
+    protected Application mContext = RuntimeEnvironment.application;
+
+    /**
+     * Test Watcher which logs test starting and finishing so log messages are easier to read.
+     */
+    @Rule
+    public TestWatcher watcher = new TestWatcher() {
+        @Override
+        protected void succeeded(Description description) {
+            super.succeeded(description);
+            logMessage(
+                    String.format("Test %s finished successfully.", description.getDisplayName()));
+        }
+
+        @Override
+        protected void failed(Throwable e, Description description) {
+            super.failed(e, description);
+            logMessage(String.format("Test %s failed.", description.getDisplayName()));
+        }
+
+        @Override
+        protected void skipped(AssumptionViolatedException e, Description description) {
+            super.skipped(e, description);
+            logMessage(String.format("Test %s is skipped.", description.getDisplayName()));
+        }
+
+        @Override
+        protected void starting(Description description) {
+            super.starting(description);
+            logMessage(String.format("Test %s started.", description.getDisplayName()));
+        }
+
+        @Override
+        protected void finished(Description description) {
+            super.finished(description);
+        }
+
+        private void logMessage(String message) {
+            System.out.println("\n*** " + message);
+        }
+    };
+
+    @Before
+    public void setUp() throws Exception {
+        DeviceShadowEnvironment.init();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        DeviceShadowEnvironment.reset();
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BluetoothTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BluetoothTestCase.java
new file mode 100644
index 0000000..cddc6fe
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BluetoothTestCase.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.testcases;
+
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+
+import android.bluetooth.BluetoothManager;
+import android.content.Context;
+
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothA2dp;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothAdapter;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothDevice;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothLeScanner;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothServerSocket;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothSocket;
+
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Base class for Bluetooth Test
+ */
+@Config(
+        shadows = {
+                ShadowBluetoothAdapter.class,
+                ShadowBluetoothDevice.class,
+                ShadowBluetoothLeScanner.class,
+                ShadowBluetoothSocket.class,
+                ShadowBluetoothServerSocket.class,
+                ShadowBluetoothA2dp.class
+        })
+public class BluetoothTestCase extends BaseTestCase {
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        // TODO(b/28087747): Get bluetooth Manager from robolectric framework.
+        shadowOf(RuntimeEnvironment.application)
+                .setSystemService(
+                        Context.BLUETOOTH_SERVICE,
+                        callConstructor(BluetoothManager.class,
+                                ClassParameter.from(Context.class, mContext)));
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/Matchers.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/Matchers.java
new file mode 100644
index 0000000..3bfe43b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/Matchers.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.testcases;
+
+import static org.mockito.ArgumentMatchers.argThat;
+
+import android.bluetooth.BluetoothSocket;
+
+import org.mockito.ArgumentMatcher;
+
+/**
+ * Convenient methods to create mockito matchers.
+ */
+public class Matchers {
+
+    private Matchers() {
+    }
+
+    public static <T extends Exception> T exception(final Class<T> clazz, final String... msgs) {
+        return argThat(
+                new ArgumentMatcher<T>() {
+                    @Override
+                    public boolean matches(T obj) {
+                        if (!clazz.isInstance(obj)) {
+                            return false;
+                        }
+                        Throwable exception = clazz.cast(obj);
+                        for (String msg : msgs) {
+                            if (exception == null || !exception.getMessage().contains(msg)) {
+                                return false;
+                            }
+                            exception = exception.getCause();
+                        }
+                        return true;
+                    }
+                });
+    }
+
+    public static BluetoothSocket socket(final String addr) {
+        return argThat(
+                new ArgumentMatcher<BluetoothSocket>() {
+                    @Override
+                    public boolean matches(BluetoothSocket obj) {
+                        return ((BluetoothSocket) obj)
+                                .getRemoteDevice()
+                                .getAddress()
+                                .toUpperCase()
+                                .equals(addr.toUpperCase());
+                    }
+                });
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/NfcTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/NfcTestCase.java
new file mode 100644
index 0000000..a80164b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/NfcTestCase.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.testcases;
+
+import com.android.libraries.testing.deviceshadower.shadows.nfc.ShadowNfcAdapter;
+
+import org.robolectric.annotation.Config;
+
+/**
+ * Base class for NFC Test
+ */
+@Config(shadows = {ShadowNfcAdapter.class})
+public class NfcTestCase extends BaseTestCase {
+
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/SmsTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/SmsTestCase.java
new file mode 100644
index 0000000..edfcc6d
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/SmsTestCase.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 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.libraries.testing.deviceshadower.testcases;
+
+import android.content.pm.ProviderInfo;
+import android.provider.Telephony;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironmentInternal;
+
+import org.robolectric.Robolectric;
+
+/**
+ * Base class for SMS Test
+ */
+public class SmsTestCase extends BaseTestCase {
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        ProviderInfo info = new ProviderInfo();
+        info.authority = Telephony.Sms.CONTENT_URI.getAuthority();
+        Robolectric.buildContentProvider(
+                        DeviceShadowEnvironmentInternal.getSmsContentProviderClass())
+                .create(info);
+    }
+}
diff --git a/nearby/tests/robotests/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairerTest.java b/nearby/tests/robotests/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairerTest.java
new file mode 100644
index 0000000..1ac2aaf
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairerTest.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright 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.common.bluetooth.fastpair;
+
+import static org.robolectric.Shadows.shadowOf;
+
+import android.Manifest.permission;
+import android.bluetooth.BluetoothAdapter;
+
+import com.android.libraries.testing.deviceshadower.Bluelet.IoCapabilities;
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothDevice;
+import com.android.libraries.testing.deviceshadower.testcases.BluetoothTestCase;
+
+import com.google.common.base.VerifyException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Tests for {@link BluetoothClassicPairer}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class BluetoothClassicPairerTest extends BluetoothTestCase {
+
+    private static final String LOCAL_DEVICE_ADDRESS = "AA:AA:AA:AA:AA:01";
+
+    /**
+     * The remote device's Bluetooth Classic address.
+     */
+    private static final String REMOTE_DEVICE_PUBLIC_ADDRESS = "BB:BB:BB:BB:BB:0C";
+
+    private Preferences.Builder mPrefsBuilder;
+
+    @Before
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        mPrefsBuilder = Preferences.builder().setCreateBondTimeoutSeconds(10);
+
+        ShadowBluetoothDevice.resetPairingConfirmation();
+        shadowOf(mContext)
+                .grantPermissions(
+                        permission.BLUETOOTH, permission.BLUETOOTH_ADMIN,
+                        permission.BLUETOOTH_PRIVILEGED);
+
+        DeviceShadowEnvironment.addDevice(LOCAL_DEVICE_ADDRESS)
+                .bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON)
+                .setIoCapabilities(IoCapabilities.DISPLAY_YES_NO);
+        DeviceShadowEnvironment.addDevice(REMOTE_DEVICE_PUBLIC_ADDRESS)
+                .bluetooth()
+                .setAdapterInitialState(BluetoothAdapter.STATE_ON)
+                .setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
+                .setIoCapabilities(IoCapabilities.DISPLAY_YES_NO);
+
+        // By default, code runs as if it's on this virtual "device".
+        DeviceShadowEnvironment.setLocalDevice(LOCAL_DEVICE_ADDRESS);
+    }
+
+    @Test
+    public void pair_setPairingConfirmationTrue_deviceBonded() throws Exception {
+    // TODO(b/217195327): replace deviceshadower with injector.
+    /*
+        AtomicReference<BluetoothDevice> targetRemoteDevice = new AtomicReference<>();
+        BluetoothClassicPairer bluetoothClassicPairer =
+                new BluetoothClassicPairer(
+                        mContext,
+                        BluetoothAdapter.getDefaultAdapter()
+                                .getRemoteDevice(REMOTE_DEVICE_PUBLIC_ADDRESS),
+                        mPrefsBuilder.build(),
+                        (BluetoothDevice remoteDevice, int key) -> {
+                            targetRemoteDevice.set(remoteDevice);
+                            // Confirms at remote device to pair with local one.
+                            setPairingConfirmationAtRemoteDevice(true);
+
+                            // Confirms to pair with remote device.
+                            remoteDevice.setPairingConfirmation(true);
+                        });
+
+        bluetoothClassicPairer.pair();
+
+        assertThat(targetRemoteDevice.get()).isNotNull();
+        assertThat(targetRemoteDevice.get().getAddress()).isEqualTo(REMOTE_DEVICE_PUBLIC_ADDRESS);
+        assertThat(targetRemoteDevice.get().getBondState()).isEqualTo(BluetoothDevice.BOND_BONDED);
+        assertThat(bluetoothClassicPairer.isPaired()).isTrue();
+    */
+    }
+
+    @Test
+    public void pair_setPairingConfirmationFalse_throwsExceptionDeviceNotBonded() throws Exception {
+    // TODO(b/217195327): replace deviceshadower with injector.
+    /*
+        AtomicReference<BluetoothDevice> targetRemoteDevice = new AtomicReference<>();
+        BluetoothClassicPairer bluetoothClassicPairer =
+                new BluetoothClassicPairer(
+                        mContext,
+                        BluetoothAdapter.getDefaultAdapter()
+                                .getRemoteDevice(REMOTE_DEVICE_PUBLIC_ADDRESS),
+                        mPrefsBuilder.build(),
+                        (BluetoothDevice remoteDevice, int key) -> {
+                            targetRemoteDevice.set(remoteDevice);
+                            // Confirms at remote device to pair with local one.
+                            setPairingConfirmationAtRemoteDevice(true);
+
+                            // Confirms NOT to pair with remote device.
+                            remoteDevice.setPairingConfirmation(false);
+                        });
+
+        assertThrows(PairingException.class, bluetoothClassicPairer::pair);
+
+        assertThat(targetRemoteDevice.get()).isNotNull();
+        assertThat(targetRemoteDevice.get().getAddress()).isEqualTo(REMOTE_DEVICE_PUBLIC_ADDRESS);
+        assertThat(targetRemoteDevice.get().getBondState()).isNotEqualTo(
+                BluetoothDevice.BOND_BONDED);
+        assertThat(bluetoothClassicPairer.isPaired()).isFalse();
+    */
+    }
+
+    @Test
+    public void pair_setPairingConfirmationIgnored_throwsExceptionDeviceNotBonded()
+            throws Exception {
+    // TODO(b/217195327): replace deviceshadower with injector.
+    /*
+        AtomicReference<BluetoothDevice> targetRemoteDevice = new AtomicReference<>();
+        BluetoothClassicPairer bluetoothClassicPairer =
+                new BluetoothClassicPairer(
+                        mContext,
+                        BluetoothAdapter.getDefaultAdapter()
+                                .getRemoteDevice(REMOTE_DEVICE_PUBLIC_ADDRESS),
+                        mPrefsBuilder.build(),
+                        (BluetoothDevice remoteDevice, int key) -> {
+                            targetRemoteDevice.set(remoteDevice);
+                            // Confirms at remote device to pair with local one.
+                            setPairingConfirmationAtRemoteDevice(true);
+
+                            // Ignores the setPairingConfirmation.
+                        });
+
+        assertThrows(PairingException.class, bluetoothClassicPairer::pair);
+        assertThat(targetRemoteDevice.get()).isNotNull();
+        assertThat(targetRemoteDevice.get().getAddress()).isEqualTo(REMOTE_DEVICE_PUBLIC_ADDRESS);
+        assertThat(targetRemoteDevice.get().getBondState()).isNotEqualTo(
+                BluetoothDevice.BOND_BONDED);
+        assertThat(bluetoothClassicPairer.isPaired()).isFalse();
+    */
+    }
+
+    private static void setPairingConfirmationAtRemoteDevice(boolean confirm) {
+        try {
+            DeviceShadowEnvironment.run(REMOTE_DEVICE_PUBLIC_ADDRESS,
+                    () -> BluetoothAdapter.getDefaultAdapter()
+                            .getRemoteDevice(LOCAL_DEVICE_ADDRESS)
+                            .setPairingConfirmation(confirm)).get();
+        } catch (InterruptedException | ExecutionException e) {
+            throw new VerifyException("failed to set pairing confirmation at remote device", e);
+        }
+    }
+}
diff --git a/nearby/tests/unit/Android.bp b/nearby/tests/unit/Android.bp
new file mode 100644
index 0000000..9b35452
--- /dev/null
+++ b/nearby/tests/unit/Android.bp
@@ -0,0 +1,57 @@
+// 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "NearbyUnitTests",
+    defaults: ["mts-target-sdk-version-current"],
+    sdk_version: "test_current",
+    min_sdk_version: "31",
+
+    // Include all test java files.
+    srcs: ["src/**/*.java"],
+
+    libs: [
+        "android.test.base",
+        "android.test.mock",
+        "android.test.runner",
+    ],
+    compile_multilib: "both",
+
+    static_libs: [
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "framework-nearby-static",
+        "guava",
+        "junit",
+        "libprotobuf-java-lite",
+        "mockito-target-extended-minus-junit4",
+        "platform-test-annotations",
+        "service-nearby-pre-jarjar",
+        "truth-prebuilt",
+        // "Robolectric_all-target",
+    ],
+    // these are needed for Extended Mockito
+    jni_libs: [
+        "libdexmakerjvmtiagent",
+        "libstaticjvmtiagent",
+    ],
+    test_suites: [
+        "general-tests",
+        "mts-tethering",
+    ],
+}
diff --git a/nearby/tests/unit/AndroidManifest.xml b/nearby/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..88c0f5f
--- /dev/null
+++ b/nearby/tests/unit/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="android.nearby.test">
+
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.nearby.test"
+        android:label="Nearby Mainline Module Tests" />
+</manifest>
diff --git a/nearby/tests/unit/AndroidTest.xml b/nearby/tests/unit/AndroidTest.xml
new file mode 100644
index 0000000..fdf665d
--- /dev/null
+++ b/nearby/tests/unit/AndroidTest.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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.
+  -->
+<configuration description="Runs Nearby Mainline API Tests.">
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="NearbyUnitTests.apk" />
+    </target_preparer>
+
+    <option name="test-suite-tag" value="apct" />
+    <option name="test-tag" value="NearbyUnitTests" />
+    <option name="config-descriptor:metadata" key="mainline-param"
+            value="com.google.android.tethering.next.apex" />
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="android.nearby.test" />
+        <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
+        <option name="hidden-api-checks" value="false"/>
+    </test>
+
+    <!-- Only run NearbyUnitTests in MTS if the Nearby Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+</configuration>
diff --git a/nearby/tests/unit/src/android/nearby/ScanRequestTest.java b/nearby/tests/unit/src/android/nearby/ScanRequestTest.java
new file mode 100644
index 0000000..020f6d6
--- /dev/null
+++ b/nearby/tests/unit/src/android/nearby/ScanRequestTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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 android.nearby;
+
+import static android.nearby.ScanRequest.SCAN_MODE_BALANCED;
+import static android.nearby.ScanRequest.SCAN_MODE_LOW_POWER;
+import static android.nearby.ScanRequest.SCAN_TYPE_EXPOSURE_NOTIFICATION;
+import static android.nearby.ScanRequest.SCAN_TYPE_FAST_PAIR;
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_SHARE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Parcel;
+import android.os.WorkSource;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Units tests for {@link ScanRequest}. */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class ScanRequestTest {
+
+    private static WorkSource getWorkSource() {
+        final int uid = 1001;
+        final String appName = "android.nearby.tests";
+        return new WorkSource(uid, appName);
+    }
+
+    /** Test creating a scan request. */
+    @Test
+    public void testScanRequestBuilder() {
+        final int scanType = SCAN_TYPE_FAST_PAIR;
+        ScanRequest request = new ScanRequest.Builder().setScanType(scanType).build();
+
+        assertThat(request.getScanType()).isEqualTo(scanType);
+        assertThat(request.getScanMode()).isEqualTo(SCAN_MODE_LOW_POWER);
+        // Work source is null if not set.
+        assertThat(request.getWorkSource().isEmpty()).isTrue();
+    }
+
+    /** Verify RuntimeException is thrown when creating scan request with invalid scan type. */
+    @Test(expected = RuntimeException.class)
+    public void testScanRequestBuilder_invalidScanType() {
+        final int invalidScanType = -1;
+        ScanRequest.Builder builder = new ScanRequest.Builder().setScanType(invalidScanType);
+
+        builder.build();
+    }
+
+    /** Verify RuntimeException is thrown when creating scan mode with invalid scan mode. */
+    @Test(expected = RuntimeException.class)
+    public void testScanModeBuilder_invalidScanType() {
+        final int invalidScanMode = -5;
+        ScanRequest.Builder builder = new ScanRequest.Builder().setScanType(
+                SCAN_TYPE_FAST_PAIR).setScanMode(invalidScanMode);
+        builder.build();
+    }
+
+    /** Verify setting work source in the scan request. */
+    @Test
+    public void testSetWorkSource() {
+        WorkSource workSource = getWorkSource();
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_SHARE)
+                .setWorkSource(workSource)
+                .build();
+
+        assertThat(request.getWorkSource()).isEqualTo(workSource);
+    }
+
+    /** Verify setting work source with null value in the scan request. */
+    @Test
+    public void testSetWorkSource_nullValue() {
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_EXPOSURE_NOTIFICATION)
+                .setWorkSource(null)
+                .build();
+
+        // Null work source is allowed.
+        assertThat(request.getWorkSource().isEmpty()).isTrue();
+    }
+
+    /** Verify toString returns expected string. */
+    @Test
+    public void testToString() {
+        WorkSource workSource = getWorkSource();
+        ScanRequest request = new ScanRequest.Builder()
+                .setScanType(SCAN_TYPE_NEARBY_SHARE)
+                .setScanMode(SCAN_MODE_BALANCED)
+                .setBleEnabled(true)
+                .setWorkSource(workSource)
+                .build();
+
+        assertThat(request.toString()).isEqualTo(
+                "Request[scanType=2, scanMode=SCAN_MODE_BALANCED, "
+                        + "enableBle=true, workSource=WorkSource{1001 android.nearby.tests}, "
+                        + "scanFilters=[]]");
+    }
+
+    /** Verify toString works correctly with null WorkSource. */
+    @Test
+    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=[]]");
+    }
+
+    /** 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)
+                .setScanMode(SCAN_MODE_BALANCED)
+                .setBleEnabled(true)
+                .setWorkSource(workSource)
+                .build();
+
+        // Write the scan request to parcel, then read from it.
+        ScanRequest request = writeReadFromParcel(originalRequest);
+
+        // Verify the request read from parcel equals to the original request.
+        assertThat(request).isEqualTo(originalRequest);
+    }
+
+    /** Verify parceling with null WorkSource. */
+    @Test
+    public void testParceling_nullWorkSource() {
+        final int scanType = SCAN_TYPE_NEARBY_PRESENCE;
+        ScanRequest originalRequest = new ScanRequest.Builder()
+                .setScanType(scanType).build();
+
+        ScanRequest request = writeReadFromParcel(originalRequest);
+
+        assertThat(request).isEqualTo(originalRequest);
+    }
+
+    private ScanRequest writeReadFromParcel(ScanRequest originalRequest) {
+        Parcel parcel = Parcel.obtain();
+        originalRequest.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        return ScanRequest.CREATOR.createFromParcel(parcel);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
new file mode 100644
index 0000000..31965a4
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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;
+
+import static android.Manifest.permission.READ_DEVICE_CONFIG;
+
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.app.UiAutomation;
+import android.content.Context;
+import android.nearby.IScanListener;
+import android.nearby.ScanRequest;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+public final class NearbyServiceTest {
+
+    private Context mContext;
+    private NearbyService mService;
+    private ScanRequest mScanRequest;
+    private UiAutomation mUiAutomation =
+            InstrumentationRegistry.getInstrumentation().getUiAutomation();
+
+    @Mock
+    private IScanListener mScanListener;
+
+    @Before
+    public void setup() {
+        initMocks(this);
+        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG);
+        mContext = InstrumentationRegistry.getInstrumentation().getContext();
+        mService = new NearbyService(mContext);
+        mScanRequest = createScanRequest();
+    }
+
+    @Test
+    public void test_register() {
+        mService.registerScanListener(mScanRequest, mScanListener);
+    }
+
+    @Test
+    public void test_unregister() {
+        mService.unregisterScanListener(mScanListener);
+    }
+
+    private ScanRequest createScanRequest() {
+        return new ScanRequest.Builder()
+                .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR)
+                .setBleEnabled(true)
+                .build();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/ble/BleFilterTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/ble/BleFilterTest.java
new file mode 100644
index 0000000..1d3653b
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/ble/BleFilterTest.java
@@ -0,0 +1,476 @@
+/*
+ * 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.common.ble;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.fail;
+
+import android.bluetooth.BluetoothDevice;
+import android.os.ParcelUuid;
+import android.util.SparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.server.nearby.common.ble.testing.FastPairTestData;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@RunWith(AndroidJUnit4.class)
+public class BleFilterTest {
+
+
+    public static final ParcelUuid EDDYSTONE_SERVICE_DATA_PARCELUUID =
+            ParcelUuid.fromString("0000FEAA-0000-1000-8000-00805F9B34FB");
+
+    private ParcelUuid mServiceDataUuid;
+    private BleSighting mBleSighting;
+    private BleFilter.Builder mFilterBuilder;
+
+    @Before
+    public void setUp() throws Exception {
+        // This is the service data UUID in TestData.sd1.
+        // Can't be static because of Robolectric.
+        mServiceDataUuid = ParcelUuid.fromString("000000E0-0000-1000-8000-00805F9B34FB");
+
+        byte[] bleRecordBytes =
+                new byte[]{
+                        0x02,
+                        0x01,
+                        0x1a, // advertising flags
+                        0x05,
+                        0x02,
+                        0x0b,
+                        0x11,
+                        0x0a,
+                        0x11, // 16 bit service uuids
+                        0x04,
+                        0x09,
+                        0x50,
+                        0x65,
+                        0x64, // setName
+                        0x02,
+                        0x0A,
+                        (byte) 0xec, // tx power level
+                        0x05,
+                        0x16,
+                        0x0b,
+                        0x11,
+                        0x50,
+                        0x64, // service data
+                        0x05,
+                        (byte) 0xff,
+                        (byte) 0xe0,
+                        0x00,
+                        0x02,
+                        0x15, // manufacturer specific data
+                        0x03,
+                        0x50,
+                        0x01,
+                        0x02, // an unknown data type won't cause trouble
+                };
+
+        mBleSighting = new BleSighting(null /* device */, bleRecordBytes,
+                -10, 1397545200000000L);
+        mFilterBuilder = new BleFilter.Builder();
+    }
+
+    @Test
+    public void setNameFilter() {
+        BleFilter filter = mFilterBuilder.setDeviceName("Ped").build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        filter = mFilterBuilder.setDeviceName("Pem").build();
+        assertThat(filter.matches(mBleSighting)).isFalse();
+    }
+
+    @Test
+    public void setServiceUuidFilter() {
+        BleFilter filter =
+                mFilterBuilder.setServiceUuid(
+                        ParcelUuid.fromString("0000110A-0000-1000-8000-00805F9B34FB"))
+                        .build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        filter =
+                mFilterBuilder.setServiceUuid(
+                        ParcelUuid.fromString("0000110C-0000-1000-8000-00805F9B34FB"))
+                        .build();
+        assertThat(filter.matches(mBleSighting)).isFalse();
+
+        filter =
+                mFilterBuilder
+                        .setServiceUuid(
+                                ParcelUuid.fromString("0000110C-0000-1000-8000-00805F9B34FB"),
+                                ParcelUuid.fromString("FFFFFFF0-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
+                        .build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+    }
+
+    @Test
+    public void setServiceDataFilter() {
+        byte[] setServiceData = new byte[]{0x50, 0x64};
+        ParcelUuid serviceDataUuid = ParcelUuid.fromString("0000110B-0000-1000-8000-00805F9B34FB");
+        BleFilter filter = mFilterBuilder.setServiceData(serviceDataUuid, setServiceData).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        byte[] emptyData = new byte[0];
+        filter = mFilterBuilder.setServiceData(serviceDataUuid, emptyData).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        byte[] prefixData = new byte[]{0x50};
+        filter = mFilterBuilder.setServiceData(serviceDataUuid, prefixData).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        byte[] nonMatchData = new byte[]{0x51, 0x64};
+        byte[] mask = new byte[]{(byte) 0x00, (byte) 0xFF};
+        filter = mFilterBuilder.setServiceData(serviceDataUuid, nonMatchData, mask).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        filter = mFilterBuilder.setServiceData(serviceDataUuid, nonMatchData).build();
+        assertThat(filter.matches(mBleSighting)).isFalse();
+    }
+
+    @Test
+    public void manufacturerSpecificData() {
+        byte[] setManufacturerData = new byte[]{0x02, 0x15};
+        int manufacturerId = 0xE0;
+        BleFilter filter =
+                mFilterBuilder.setManufacturerData(manufacturerId, setManufacturerData).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        byte[] emptyData = new byte[0];
+        filter = mFilterBuilder.setManufacturerData(manufacturerId, emptyData).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        byte[] prefixData = new byte[]{0x02};
+        filter = mFilterBuilder.setManufacturerData(manufacturerId, prefixData).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        // Data and mask are nullable. Check that we still match when they're null.
+        filter = mFilterBuilder.setManufacturerData(manufacturerId,
+                null /* data */).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+        filter = mFilterBuilder.setManufacturerData(manufacturerId,
+                null /* data */, null /* mask */).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+
+        // Test data mask
+        byte[] nonMatchData = new byte[]{0x02, 0x14};
+        filter = mFilterBuilder.setManufacturerData(manufacturerId, nonMatchData).build();
+        assertThat(filter.matches(mBleSighting)).isFalse();
+        byte[] mask = new byte[]{(byte) 0xFF, (byte) 0x00};
+        filter = mFilterBuilder.setManufacturerData(manufacturerId, nonMatchData, mask).build();
+        assertThat(filter.matches(mBleSighting)).isTrue();
+    }
+
+    @Test
+    public void manufacturerDataNotInBleRecord() {
+        byte[] bleRecord = FastPairTestData.adv_2;
+        // Verify manufacturer with no data
+        byte[] data = {(byte) 0xe0, (byte) 0x00};
+        BleFilter filter = mFilterBuilder.setManufacturerData(0x00e0, data).build();
+        assertThat(matches(filter, null, 0, bleRecord)).isFalse();
+    }
+
+    @Test
+    public void manufacturerDataMaskNotInBleRecord() {
+        byte[] bleRecord = FastPairTestData.adv_2;
+
+        // Verify matching partial manufacturer with data and mask
+        byte[] data = {(byte) 0x15};
+        byte[] mask = {(byte) 0xff};
+
+        BleFilter filter = mFilterBuilder
+                .setManufacturerData(0x00e0, data, mask).build();
+        assertThat(matches(filter, null, 0, bleRecord)).isFalse();
+    }
+
+
+    @Test
+    public void serviceData() throws Exception {
+        byte[] bleRecord = FastPairTestData.sd1;
+        byte[] serviceData = {(byte) 0x15};
+
+        // Verify manufacturer 2-byte UUID with no data
+        BleFilter filter = mFilterBuilder.setServiceData(mServiceDataUuid, serviceData).build();
+        assertMatches(filter, null, 0, bleRecord);
+    }
+
+    @Test
+    public void serviceDataNoMatch() {
+        byte[] bleRecord = FastPairTestData.sd1;
+        byte[] serviceData = {(byte) 0xe1, (byte) 0x00};
+
+        // Verify manufacturer 2-byte UUID with no data
+        BleFilter filter = mFilterBuilder.setServiceData(mServiceDataUuid, serviceData).build();
+        assertThat(matches(filter, null, 0, bleRecord)).isFalse();
+    }
+
+    @Test
+    public void serviceDataMask() {
+        byte[] bleRecord = FastPairTestData.sd1;
+        BleFilter filter;
+
+        // Verify matching partial manufacturer with data and mask
+        byte[] serviceData1 = {(byte) 0x15};
+        byte[] mask1 = {(byte) 0xff};
+        filter = mFilterBuilder.setServiceData(mServiceDataUuid, serviceData1, mask1).build();
+        assertMatches(filter, null, 0, bleRecord);
+    }
+
+    @Test
+    public void serviceDataMaskNoMatch() {
+        byte[] bleRecord = FastPairTestData.sd1;
+        BleFilter filter;
+
+        // Verify non-matching partial manufacturer with data and mask
+        byte[] serviceData2 = {(byte) 0xe0, (byte) 0x00, (byte) 0x10};
+        byte[] mask2 = {(byte) 0xff, (byte) 0xff, (byte) 0xff};
+        filter = mFilterBuilder.setServiceData(mServiceDataUuid, serviceData2, mask2).build();
+        assertThat(matches(filter, null, 0, bleRecord)).isFalse();
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void serviceDataMaskWithDifferentLength() {
+        // Different lengths for data and mask.
+        byte[] serviceData = {(byte) 0xe0, (byte) 0x00, (byte) 0x10};
+        byte[] mask = {(byte) 0xff, (byte) 0xff};
+
+        //expected.expect(IllegalArgumentException.class);
+
+        mFilterBuilder.setServiceData(mServiceDataUuid, serviceData, mask).build();
+    }
+
+
+    @Test
+    public void deviceNameTest() {
+        // Verify the name filter matches
+        byte[] bleRecord = FastPairTestData.adv_1;
+        BleFilter filter = mFilterBuilder.setDeviceName("Pedometer").build();
+        assertMatches(filter, null, 0, bleRecord);
+    }
+
+    @Test
+    public void deviceNameNoMatch() {
+        // Verify the name filter does not match
+        byte[] bleRecord = FastPairTestData.adv_1;
+        BleFilter filter = mFilterBuilder.setDeviceName("Foo").build();
+        assertThat(matches(filter, null, 0, bleRecord)).isFalse();
+    }
+
+    private static boolean matches(
+            BleFilter filter, BluetoothDevice device, int rssi, byte[] bleRecord) {
+        return filter.matches(new BleSighting(device,
+                bleRecord, rssi, 0 /* timestampNanos */));
+    }
+
+
+    private static void assertMatches(
+            BleFilter filter, BluetoothDevice device, int rssi, byte[] bleRecordBytes) {
+
+        // Device match.
+        if (filter.getDeviceAddress() != null
+                && (device == null || !filter.getDeviceAddress().equals(device.getAddress()))) {
+            fail("Filter specified a device address ("
+                    + filter.getDeviceAddress()
+                    + ") which doesn't match the actual value: ["
+                    + (device == null ? "null device" : device.getAddress())
+                    + "]");
+        }
+
+        // BLE record is null but there exist filters on it.
+        BleRecord bleRecord = BleRecord.parseFromBytes(bleRecordBytes);
+        if (bleRecord == null
+                && (filter.getDeviceName() != null
+                || filter.getServiceUuid() != null
+                || filter.getManufacturerData() != null
+                || filter.getServiceData() != null)) {
+            fail(
+                    "The bleRecordBytes given parsed to a null bleRecord, but the filter"
+                            + "has a non-null field which depends on the scan record");
+        }
+
+        // Local name match.
+        if (filter.getDeviceName() != null
+                && !filter.getDeviceName().equals(bleRecord.getDeviceName())) {
+            fail(
+                    "The filter's device name ("
+                            + filter.getDeviceName()
+                            + ") doesn't match the scan record device name ("
+                            + bleRecord.getDeviceName()
+                            + ")");
+        }
+
+        // UUID match.
+        if (filter.getServiceUuid() != null
+                && !matchesServiceUuids(filter.getServiceUuid(), filter.getServiceUuidMask(),
+                bleRecord.getServiceUuids())) {
+            fail("The filter specifies a service UUID but it doesn't match "
+                    + "what's in the scan record");
+        }
+
+        // Service data match
+        if (filter.getServiceDataUuid() != null
+                && !BleFilter.matchesPartialData(
+                filter.getServiceData(),
+                filter.getServiceDataMask(),
+                bleRecord.getServiceData(filter.getServiceDataUuid()))) {
+            fail(
+                    "The filter's service data doesn't match what's in the scan record.\n"
+                            + "Service data: "
+                            + byteString(filter.getServiceData())
+                            + "\n"
+                            + "Service data UUID: "
+                            + filter.getServiceDataUuid().toString()
+                            + "\n"
+                            + "Service data mask: "
+                            + byteString(filter.getServiceDataMask())
+                            + "\n"
+                            + "Scan record service data: "
+                            + byteString(bleRecord.getServiceData(filter.getServiceDataUuid()))
+                            + "\n"
+                            + "Scan record data map:\n"
+                            + byteString(bleRecord.getServiceData()));
+        }
+
+        // Manufacturer data match.
+        if (filter.getManufacturerId() >= 0
+                && !BleFilter.matchesPartialData(
+                filter.getManufacturerData(),
+                filter.getManufacturerDataMask(),
+                bleRecord.getManufacturerSpecificData(filter.getManufacturerId()))) {
+            fail(
+                    "The filter's manufacturer data doesn't match what's in the scan record.\n"
+                            + "Manufacturer ID: "
+                            + filter.getManufacturerId()
+                            + "\n"
+                            + "Manufacturer data: "
+                            + byteString(filter.getManufacturerData())
+                            + "\n"
+                            + "Manufacturer data mask: "
+                            + byteString(filter.getManufacturerDataMask())
+                            + "\n"
+                            + "Scan record manufacturer-specific data: "
+                            + byteString(bleRecord.getManufacturerSpecificData(
+                            filter.getManufacturerId()))
+                            + "\n"
+                            + "Manufacturer data array:\n"
+                            + byteString(bleRecord.getManufacturerSpecificData()));
+        }
+
+        // All filters match.
+        assertThat(
+                matches(filter, device, rssi, bleRecordBytes)).isTrue();
+    }
+
+
+    private static String byteString(byte[] bytes) {
+        if (bytes == null) {
+            return "[null]";
+        } else {
+            final char[] hexArray = "0123456789ABCDEF".toCharArray();
+            char[] hexChars = new char[bytes.length * 2];
+            for (int i = 0; i < bytes.length; i++) {
+                int v = bytes[i] & 0xFF;
+                hexChars[i * 2] = hexArray[v >>> 4];
+                hexChars[i * 2 + 1] = hexArray[v & 0x0F];
+            }
+            return new String(hexChars);
+        }
+    }
+
+    // Ref to beacon.decode.AppleBeaconDecoder.getFilterData
+    private static byte[] getFilterData(ParcelUuid uuid) {
+        byte[] data = new byte[18];
+        data[0] = (byte) 0x02;
+        data[1] = (byte) 0x15;
+        // Check if UUID is needed in data
+        if (uuid != null) {
+            // Convert UUID to array in big endian order
+            byte[] uuidBytes = uuidToByteArray(uuid);
+            for (int i = 0; i < 16; i++) {
+                // Adding uuid bytes in big-endian order to match iBeacon format
+                data[i + 2] = uuidBytes[i];
+            }
+        }
+        return data;
+    }
+
+    // Ref to beacon.decode.AppleBeaconDecoder.uuidToByteArray
+    private static byte[] uuidToByteArray(ParcelUuid uuid) {
+        ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
+        bb.putLong(uuid.getUuid().getMostSignificantBits());
+        bb.putLong(uuid.getUuid().getLeastSignificantBits());
+        return bb.array();
+    }
+
+    private static boolean matchesServiceUuids(
+            ParcelUuid uuid, ParcelUuid parcelUuidMask, List<ParcelUuid> uuids) {
+        if (uuid == null) {
+            return true;
+        }
+
+        for (ParcelUuid parcelUuid : uuids) {
+            UUID uuidMask = parcelUuidMask == null ? null : parcelUuidMask.getUuid();
+            if (matchesServiceUuid(uuid.getUuid(), uuidMask, parcelUuid.getUuid())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // Check if the uuid pattern matches the particular service uuid.
+    private static boolean matchesServiceUuid(UUID uuid, UUID mask, UUID data) {
+        if (mask == null) {
+            return uuid.equals(data);
+        }
+        if ((uuid.getLeastSignificantBits() & mask.getLeastSignificantBits())
+                != (data.getLeastSignificantBits() & mask.getLeastSignificantBits())) {
+            return false;
+        }
+        return ((uuid.getMostSignificantBits() & mask.getMostSignificantBits())
+                == (data.getMostSignificantBits() & mask.getMostSignificantBits()));
+    }
+
+    private static String byteString(Map<ParcelUuid, byte[]> bytesMap) {
+        StringBuilder builder = new StringBuilder();
+        for (Map.Entry<ParcelUuid, byte[]> entry : bytesMap.entrySet()) {
+            builder.append(builder.toString().isEmpty() ? "  " : "\n  ");
+            builder.append(entry.getKey().toString());
+            builder.append(" --> ");
+            builder.append(byteString(entry.getValue()));
+        }
+        return builder.toString();
+    }
+
+    private static String byteString(SparseArray<byte[]> bytesArray) {
+        StringBuilder builder = new StringBuilder();
+        for (int i = 0; i < bytesArray.size(); i++) {
+            builder.append(builder.toString().isEmpty() ? "  " : "\n  ");
+            builder.append(byteString(bytesArray.valueAt(i)));
+        }
+        return builder.toString();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/ble/BleRecordTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/ble/BleRecordTest.java
new file mode 100644
index 0000000..5da98e2
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/ble/BleRecordTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.
+ */
+
+/*
+ * 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.common.ble;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Test;
+
+/** Test for Bluetooth LE {@link BleRecord}. */
+public class BleRecordTest {
+
+    // iBeacon (Apple) Packet 1
+    private static final byte[] BEACON = {
+            // Flags
+            (byte) 0x02,
+            (byte) 0x01,
+            (byte) 0x06,
+            // Manufacturer-specific data header
+            (byte) 0x1a,
+            (byte) 0xff,
+            (byte) 0x4c,
+            (byte) 0x00,
+            // iBeacon Type
+            (byte) 0x02,
+            // Frame length
+            (byte) 0x15,
+            // iBeacon Proximity UUID
+            (byte) 0xf7,
+            (byte) 0x82,
+            (byte) 0x6d,
+            (byte) 0xa6,
+            (byte) 0x4f,
+            (byte) 0xa2,
+            (byte) 0x4e,
+            (byte) 0x98,
+            (byte) 0x80,
+            (byte) 0x24,
+            (byte) 0xbc,
+            (byte) 0x5b,
+            (byte) 0x71,
+            (byte) 0xe0,
+            (byte) 0x89,
+            (byte) 0x3e,
+            // iBeacon Instance ID (Major/Minor)
+            (byte) 0x44,
+            (byte) 0xd0,
+            (byte) 0x25,
+            (byte) 0x22,
+            // Tx Power
+            (byte) 0xb3,
+            // RSP
+            (byte) 0x08,
+            (byte) 0x09,
+            (byte) 0x4b,
+            (byte) 0x6f,
+            (byte) 0x6e,
+            (byte) 0x74,
+            (byte) 0x61,
+            (byte) 0x6b,
+            (byte) 0x74,
+            (byte) 0x02,
+            (byte) 0x0a,
+            (byte) 0xf4,
+            (byte) 0x0a,
+            (byte) 0x16,
+            (byte) 0x0d,
+            (byte) 0xd0,
+            (byte) 0x74,
+            (byte) 0x6d,
+            (byte) 0x4d,
+            (byte) 0x6b,
+            (byte) 0x32,
+            (byte) 0x36,
+            (byte) 0x64,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00
+    };
+
+    // iBeacon (Apple) Packet 1
+    private static final byte[] SAME_BEACON = {
+            // Flags
+            (byte) 0x02,
+            (byte) 0x01,
+            (byte) 0x06,
+            // Manufacturer-specific data header
+            (byte) 0x1a,
+            (byte) 0xff,
+            (byte) 0x4c,
+            (byte) 0x00,
+            // iBeacon Type
+            (byte) 0x02,
+            // Frame length
+            (byte) 0x15,
+            // iBeacon Proximity UUID
+            (byte) 0xf7,
+            (byte) 0x82,
+            (byte) 0x6d,
+            (byte) 0xa6,
+            (byte) 0x4f,
+            (byte) 0xa2,
+            (byte) 0x4e,
+            (byte) 0x98,
+            (byte) 0x80,
+            (byte) 0x24,
+            (byte) 0xbc,
+            (byte) 0x5b,
+            (byte) 0x71,
+            (byte) 0xe0,
+            (byte) 0x89,
+            (byte) 0x3e,
+            // iBeacon Instance ID (Major/Minor)
+            (byte) 0x44,
+            (byte) 0xd0,
+            (byte) 0x25,
+            (byte) 0x22,
+            // Tx Power
+            (byte) 0xb3,
+            // RSP
+            (byte) 0x08,
+            (byte) 0x09,
+            (byte) 0x4b,
+            (byte) 0x6f,
+            (byte) 0x6e,
+            (byte) 0x74,
+            (byte) 0x61,
+            (byte) 0x6b,
+            (byte) 0x74,
+            (byte) 0x02,
+            (byte) 0x0a,
+            (byte) 0xf4,
+            (byte) 0x0a,
+            (byte) 0x16,
+            (byte) 0x0d,
+            (byte) 0xd0,
+            (byte) 0x74,
+            (byte) 0x6d,
+            (byte) 0x4d,
+            (byte) 0x6b,
+            (byte) 0x32,
+            (byte) 0x36,
+            (byte) 0x64,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00,
+            (byte) 0x00
+    };
+
+    // iBeacon (Apple) Packet 1 with a modified second field.
+    private static final byte[] OTHER_BEACON = {
+            (byte) 0x02, // Length of this Data
+            (byte) 0x02, // <<Flags>>
+            (byte) 0x04, // BR/EDR Not Supported.
+            // Apple Specific Data
+            26, // length of data that follows
+            (byte) 0xff, // <<Manufacturer Specific Data>>
+            // Company Identifier Code = Apple
+            (byte) 0x4c, // LSB
+            (byte) 0x00, // MSB
+            // iBeacon Header
+            0x02,
+            // iBeacon Length
+            0x15,
+            // UUID = PROXIMITY_NOW
+            // IEEE 128-bit UUID represented as UUID[15]: msb To UUID[0]: lsb
+            (byte) 0x14,
+            (byte) 0xe4,
+            (byte) 0xfd,
+            (byte) 0x9f, // UUID[15] - UUID[12]
+            (byte) 0x66,
+            (byte) 0x67,
+            (byte) 0x4c,
+            (byte) 0xcb, // UUID[11] - UUID[08]
+            (byte) 0xa6,
+            (byte) 0x1b,
+            (byte) 0x24,
+            (byte) 0xd0, // UUID[07] - UUID[04]
+            (byte) 0x9a,
+            (byte) 0xb1,
+            (byte) 0x7e,
+            (byte) 0x93, // UUID[03] - UUID[00]
+            // ID as an int (decimal) = 1297482358
+            (byte) 0x76, // Major H
+            (byte) 0x02, // Major L
+            (byte) 0x56, // Minor H
+            (byte) 0x4d, // Minor L
+            // Normalized Tx Power of -77dbm
+            (byte) 0xb3,
+            0x00, // Zero padding for testing
+    };
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEquals() {
+        BleRecord record = BleRecord.parseFromBytes(BEACON);
+        BleRecord record2 = BleRecord.parseFromBytes(SAME_BEACON);
+
+
+        assertThat(record).isEqualTo(record2);
+
+        // Different items.
+        record2 = BleRecord.parseFromBytes(OTHER_BEACON);
+        assertThat(record).isNotEqualTo(record2);
+        assertThat(record.hashCode()).isNotEqualTo(record2.hashCode());
+    }
+}
+
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/ble/decode/FastPairDecoderTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/ble/decode/FastPairDecoderTest.java
new file mode 100644
index 0000000..1ad04f8
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/ble/decode/FastPairDecoderTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.common.ble.decode;
+
+import static com.android.server.nearby.common.ble.BleRecord.parseFromBytes;
+import static com.android.server.nearby.common.ble.testing.FastPairTestData.FAST_PAIR_MODEL_ID;
+import static com.android.server.nearby.common.ble.testing.FastPairTestData.getFastPairRecord;
+import static com.android.server.nearby.common.ble.testing.FastPairTestData.newFastPairRecord;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.server.nearby.common.ble.BleRecord;
+import com.android.server.nearby.util.Hex;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class FastPairDecoderTest {
+    private static final String LONG_MODEL_ID = "1122334455667788";
+    private final FastPairDecoder mDecoder = new FastPairDecoder();
+    // Bits 3-6 are model ID length bits = 0b1000 = 8
+    private static final byte LONG_MODEL_ID_HEADER = 0b00010000;
+    private static final String PADDED_LONG_MODEL_ID = "00001111";
+    // Bits 3-6 are model ID length bits = 0b0100 = 4
+    private static final byte PADDED_LONG_MODEL_ID_HEADER = 0b00001000;
+    private static final String TRIMMED_LONG_MODEL_ID = "001111";
+    private static final byte MODEL_ID_HEADER = 0b00000110;
+    private static final String MODEL_ID = "112233";
+    private static final byte BLOOM_FILTER_HEADER = 0b01100000;
+    private static final String BLOOM_FILTER = "112233445566";
+    private static final byte BLOOM_FILTER_SALT_HEADER = 0b00010001;
+    private static final String BLOOM_FILTER_SALT = "01";
+    private static final byte RANDOM_RESOLVABLE_DATA_HEADER = 0b01000110;
+    private static final String RANDOM_RESOLVABLE_DATA = "11223344";
+    private static final byte BLOOM_FILTER_NO_NOTIFICATION_HEADER = 0b01100010;
+
+
+    @Test
+    public void getModelId() {
+        assertThat(mDecoder.getBeaconIdBytes(parseFromBytes(getFastPairRecord())))
+                .isEqualTo(FAST_PAIR_MODEL_ID);
+        FastPairServiceData fastPairServiceData1 =
+                new FastPairServiceData(LONG_MODEL_ID_HEADER,
+                        LONG_MODEL_ID);
+        assertThat(
+                mDecoder.getBeaconIdBytes(
+                        newBleRecord(fastPairServiceData1.createServiceData())))
+                .isEqualTo(Hex.stringToBytes(LONG_MODEL_ID));
+        FastPairServiceData fastPairServiceData =
+                new FastPairServiceData(PADDED_LONG_MODEL_ID_HEADER,
+                        PADDED_LONG_MODEL_ID);
+        assertThat(
+                mDecoder.getBeaconIdBytes(
+                        newBleRecord(fastPairServiceData.createServiceData())))
+                .isEqualTo(Hex.stringToBytes(TRIMMED_LONG_MODEL_ID));
+    }
+
+    @Test
+    public void getBloomFilter() {
+        FastPairServiceData fastPairServiceData = new FastPairServiceData(MODEL_ID_HEADER,
+                MODEL_ID);
+        fastPairServiceData.mExtraFieldHeaders.add(BLOOM_FILTER_HEADER);
+        fastPairServiceData.mExtraFields.add(BLOOM_FILTER);
+        assertThat(FastPairDecoder.getBloomFilter(fastPairServiceData.createServiceData()))
+                .isEqualTo(Hex.stringToBytes(BLOOM_FILTER));
+    }
+
+    @Test
+    public void getBloomFilter_smallModelId() {
+        FastPairServiceData fastPairServiceData = new FastPairServiceData(null, MODEL_ID);
+        assertThat(FastPairDecoder.getBloomFilter(fastPairServiceData.createServiceData()))
+                .isNull();
+    }
+
+    @Test
+    public void getBloomFilterSalt_modelIdAndMultipleExtraFields() {
+        FastPairServiceData fastPairServiceData = new FastPairServiceData(MODEL_ID_HEADER,
+                MODEL_ID);
+        fastPairServiceData.mExtraFieldHeaders.add(BLOOM_FILTER_HEADER);
+        fastPairServiceData.mExtraFieldHeaders.add(BLOOM_FILTER_SALT_HEADER);
+        fastPairServiceData.mExtraFields.add(BLOOM_FILTER);
+        fastPairServiceData.mExtraFields.add(BLOOM_FILTER_SALT);
+        assertThat(
+                FastPairDecoder.getBloomFilterSalt(fastPairServiceData.createServiceData()))
+                .isEqualTo(Hex.stringToBytes(BLOOM_FILTER_SALT));
+    }
+
+    @Test
+    public void getRandomResolvableData_whenContainConnectionState() {
+        FastPairServiceData fastPairServiceData = new FastPairServiceData(MODEL_ID_HEADER,
+                MODEL_ID);
+        fastPairServiceData.mExtraFieldHeaders.add(RANDOM_RESOLVABLE_DATA_HEADER);
+        fastPairServiceData.mExtraFields.add(RANDOM_RESOLVABLE_DATA);
+        assertThat(
+                FastPairDecoder.getRandomResolvableData(fastPairServiceData
+                                .createServiceData()))
+                .isEqualTo(Hex.stringToBytes(RANDOM_RESOLVABLE_DATA));
+    }
+
+    @Test
+    public void getBloomFilterNoNotification() {
+        FastPairServiceData fastPairServiceData =
+                new FastPairServiceData(MODEL_ID_HEADER, MODEL_ID);
+        fastPairServiceData.mExtraFieldHeaders.add(BLOOM_FILTER_NO_NOTIFICATION_HEADER);
+        fastPairServiceData.mExtraFields.add(BLOOM_FILTER);
+        assertThat(FastPairDecoder.getBloomFilterNoNotification(fastPairServiceData
+                        .createServiceData())).isEqualTo(Hex.stringToBytes(BLOOM_FILTER));
+    }
+
+    private static BleRecord newBleRecord(byte[] serviceDataBytes) {
+        return parseFromBytes(newFastPairRecord(serviceDataBytes));
+    }
+    class FastPairServiceData {
+        private Byte mHeader;
+        private String mModelId;
+        List<Byte> mExtraFieldHeaders = new ArrayList<>();
+        List<String> mExtraFields = new ArrayList<>();
+
+        FastPairServiceData(Byte header, String modelId) {
+            this.mHeader = header;
+            this.mModelId = modelId;
+        }
+        private byte[] createServiceData() {
+            if (mExtraFieldHeaders.size() != mExtraFields.size()) {
+                throw new RuntimeException("Number of headers and extra fields must match.");
+            }
+            byte[] serviceData =
+                    Bytes.concat(
+                            mHeader == null ? new byte[0] : new byte[] {mHeader},
+                            mModelId == null ? new byte[0] : Hex.stringToBytes(mModelId));
+            for (int i = 0; i < mExtraFieldHeaders.size(); i++) {
+                serviceData =
+                        Bytes.concat(
+                                serviceData,
+                                mExtraFieldHeaders.get(i) != null
+                                        ? new byte[] {mExtraFieldHeaders.get(i)}
+                                        : new byte[0],
+                                mExtraFields.get(i) != null
+                                        ? Hex.stringToBytes(mExtraFields.get(i))
+                                        : new byte[0]);
+            }
+            return serviceData;
+        }
+    }
+
+
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/ble/util/RangingUtilsTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/ble/util/RangingUtilsTest.java
new file mode 100644
index 0000000..2926015
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/ble/util/RangingUtilsTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.common.ble.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class RangingUtilsTest {
+    // relative error to be used in comparing doubles
+    private static final double DELTA = 1e-5;
+
+    @Test
+    public void distanceFromRssi_getCorrectValue() {
+        // Distance expected to be 1.0 meters based on an RSSI/TxPower of -41dBm
+        // Using params: int rssi (dBm), int calibratedTxPower (dBm)
+        double distance = RangingUtils.distanceFromRssi(-41, -41);
+        assertThat(distance).isWithin(DELTA).of(1.0);
+
+        double distance2 = RangingUtils.distanceFromRssi(-70, -50);
+        assertThat(distance2).isWithin(DELTA).of(10.0);
+
+        // testing that the double values are not casted to integers
+        double distance3 = RangingUtils.distanceFromRssi(-67, -77);
+        assertThat(distance3).isWithin(DELTA).of(0.31622776601683794);
+
+        double distance4 = RangingUtils.distanceFromRssi(-50, -70);
+        assertThat(distance4).isWithin(DELTA).of(0.1);
+    }
+
+    @Test
+    public void testRssiFromDistance() {
+        // RSSI expected at 1 meter based on the calibrated tx field of -41dBm
+        // Using params: distance (m), int calibratedTxPower (dBm),
+        int rssi = RangingUtils.rssiFromDistance(1.0, -41);
+
+        assertThat(rssi).isEqualTo(-41);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGeneratorTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGeneratorTest.java
new file mode 100644
index 0000000..35a45c0
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGeneratorTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AccountKeyCharacteristic;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Unit tests for {@link AccountKeyGenerator}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class AccountKeyGeneratorTest {
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void createAccountKey() throws NoSuchAlgorithmException {
+        byte[] accountKey = AccountKeyGenerator.createAccountKey();
+
+        assertThat(accountKey).hasLength(16);
+        assertThat(accountKey[0]).isEqualTo(AccountKeyCharacteristic.TYPE);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AdditionalDataEncoderTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AdditionalDataEncoderTest.java
new file mode 100644
index 0000000..28d2fca
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AdditionalDataEncoderTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AdditionalDataEncoder.MAX_LENGTH_OF_DATA;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.KEY_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.NONCE_SIZE;
+import static com.android.server.nearby.common.bluetooth.fastpair.NamingEncoder.EXTRACT_HMAC_SIZE;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.security.GeneralSecurityException;
+
+/**
+ * Unit tests for {@link AdditionalDataEncoder}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class AdditionalDataEncoderTest {
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void decodeEncodedAdditionalDataPacket_mustGetSameRawData()
+            throws GeneralSecurityException {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] rawData = base16().decode("00112233445566778899AABBCCDDEEFF");
+
+        byte[] encodedAdditionalDataPacket =
+                AdditionalDataEncoder.encodeAdditionalDataPacket(secret, rawData);
+        byte[] additionalData =
+                AdditionalDataEncoder
+                        .decodeAdditionalDataPacket(secret, encodedAdditionalDataPacket);
+
+        assertThat(additionalData).isEqualTo(rawData);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectKeySizeToEncode_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH - 1];
+        byte[] rawData = base16().decode("00112233445566778899AABBCCDDEEFF");
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> AdditionalDataEncoder.encodeAdditionalDataPacket(secret, rawData));
+
+        assertThat(exception)
+                .hasMessageThat()
+                .contains("Incorrect secret for encoding additional data packet");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectKeySizeToDecode_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH - 1];
+        byte[] packet = base16().decode("01234567890123456789");
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> AdditionalDataEncoder.decodeAdditionalDataPacket(secret, packet));
+
+        assertThat(exception)
+                .hasMessageThat()
+                .contains("Incorrect secret for decoding additional data packet");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTooSmallPacketSize_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH];
+        byte[] packet = new byte[EXTRACT_HMAC_SIZE - 1];
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> AdditionalDataEncoder.decodeAdditionalDataPacket(secret, packet));
+
+        assertThat(exception).hasMessageThat().contains("Additional data packet size is incorrect");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTooLargePacketSize_mustThrowException() throws GeneralSecurityException {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] packet = new byte[MAX_LENGTH_OF_DATA + EXTRACT_HMAC_SIZE + NONCE_SIZE + 1];
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> AdditionalDataEncoder.decodeAdditionalDataPacket(secret, packet));
+
+        assertThat(exception).hasMessageThat().contains("Additional data packet size is incorrect");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectHmacToDecode_mustThrowException() throws GeneralSecurityException {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] rawData = base16().decode("00112233445566778899AABBCCDDEEFF");
+
+        byte[] additionalDataPacket = AdditionalDataEncoder
+                .encodeAdditionalDataPacket(secret, rawData);
+        additionalDataPacket[0] = (byte) ~additionalDataPacket[0];
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> AdditionalDataEncoder
+                                .decodeAdditionalDataPacket(secret, additionalDataPacket));
+
+        assertThat(exception)
+                .hasMessageThat()
+                .contains("Verify HMAC failed, could be incorrect key or packet.");
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AesCtrMultipleBlockEncryptionTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AesCtrMultipleBlockEncryptionTest.java
new file mode 100644
index 0000000..7d86037
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AesCtrMultipleBlockEncryptionTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.NONCE_SIZE;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.KEY_LENGTH;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+/** Unit tests for {@link AesCtrMultpleBlockEncryption}. */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class AesCtrMultipleBlockEncryptionTest {
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void decryptEncryptedData_nonBlockSizeAligned_mustEqualToPlaintext() throws Exception {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] plaintext = "Someone's Google Headphone 2019".getBytes(UTF_8); // The length is 31.
+
+        byte[] encrypted = AesCtrMultipleBlockEncryption.encrypt(secret, plaintext);
+        byte[] decrypted = AesCtrMultipleBlockEncryption.decrypt(secret, encrypted);
+
+        assertThat(decrypted).isEqualTo(plaintext);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void decryptEncryptedData_blockSizeAligned_mustEqualToPlaintext() throws Exception {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] plaintext =
+                // The length is 32.
+                base16().decode("0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF");
+
+        byte[] encrypted = AesCtrMultipleBlockEncryption.encrypt(secret, plaintext);
+        byte[] decrypted = AesCtrMultipleBlockEncryption.decrypt(secret, encrypted);
+
+        assertThat(decrypted).isEqualTo(plaintext);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void generateNonceTwice_mustBeDifferent() {
+        byte[] nonce1 = AesCtrMultipleBlockEncryption.generateNonce();
+        byte[] nonce2 = AesCtrMultipleBlockEncryption.generateNonce();
+
+        assertThat(nonce1).isNotEqualTo(nonce2);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void encryptedSamePlaintext_mustBeDifferentEncryptedResult() throws Exception {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] plaintext = "Someone's Google Headphone 2019".getBytes(UTF_8);
+
+        byte[] encrypted1 = AesCtrMultipleBlockEncryption.encrypt(secret, plaintext);
+        byte[] encrypted2 = AesCtrMultipleBlockEncryption.encrypt(secret, plaintext);
+
+        assertThat(encrypted1).isNotEqualTo(encrypted2);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void encryptData_mustBeDifferentToUnencrypted() throws Exception {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] plaintext = "Someone's Google Headphone 2019".getBytes(UTF_8);
+
+        byte[] encrypted = AesCtrMultipleBlockEncryption.encrypt(secret, plaintext);
+
+        assertThat(encrypted).isNotEqualTo(plaintext);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectKeySizeToEncrypt_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH + 1];
+        byte[] plaintext = "Someone's Google Headphone 2019".getBytes(UTF_8);
+
+        IllegalArgumentException exception =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> AesCtrMultipleBlockEncryption.encrypt(secret, plaintext));
+
+        assertThat(exception)
+                .hasMessageThat()
+                .contains("Incorrect key length for encryption, only supports 16-byte AES Key.");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectKeySizeToDecrypt_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH - 1];
+        byte[] plaintext = "Someone's Google Headphone 2019".getBytes(UTF_8);
+
+        IllegalArgumentException exception =
+                assertThrows(
+                        IllegalArgumentException.class,
+                        () -> AesCtrMultipleBlockEncryption.decrypt(secret, plaintext));
+
+        assertThat(exception)
+                .hasMessageThat()
+                .contains("Incorrect key length for encryption, only supports 16-byte AES Key.");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectDataSizeToDecrypt_mustThrowException()
+            throws GeneralSecurityException {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] plaintext = "Someone's Google Headphone 2019".getBytes(UTF_8);
+
+        byte[] encryptedData = Arrays.copyOfRange(
+                AesCtrMultipleBlockEncryption.encrypt(secret, plaintext), /*from=*/ 0, NONCE_SIZE);
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> AesCtrMultipleBlockEncryption.decrypt(secret, encryptedData));
+
+        assertThat(exception).hasMessageThat().contains("Incorrect data length");
+    }
+
+    // Add some random tests that for a certain amount of random plaintext of random length to prove
+    // our encryption/decryption is correct. This is suggested by security team.
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void decryptEncryptedRandomDataForCertainAmount_mustEqualToOriginalData()
+            throws Exception {
+        SecureRandom random = new SecureRandom();
+        for (int i = 0; i < 1000; i++) {
+            byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+            int dataLength = random.nextInt(64) + 1;
+            byte[] data = new byte[dataLength];
+            random.nextBytes(data);
+
+            byte[] encrypted = AesCtrMultipleBlockEncryption.encrypt(secret, data);
+            byte[] decrypted = AesCtrMultipleBlockEncryption.decrypt(secret, encrypted);
+
+            assertThat(decrypted).isEqualTo(data);
+        }
+    }
+
+    // Add some random tests that for a certain amount of random plaintext of random length to prove
+    // our encryption is correct. This is suggested by security team.
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void twoDistinctEncryptionOnSameRandomData_mustBeDifferentResult() throws Exception {
+        SecureRandom random = new SecureRandom();
+        for (int i = 0; i < 1000; i++) {
+            byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+            int dataLength = random.nextInt(64) + 1;
+            byte[] data = new byte[dataLength];
+            random.nextBytes(data);
+
+            byte[] encrypted1 = AesCtrMultipleBlockEncryption.encrypt(secret, data);
+            byte[] encrypted2 = AesCtrMultipleBlockEncryption.encrypt(secret, data);
+
+            assertThat(encrypted1).isNotEqualTo(encrypted2);
+        }
+    }
+
+    // Adds this test example on spec. Also we can easily change the parameters(e.g. secret, data,
+    // nonce) to clarify test results with partners.
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTestExampleToEncrypt_getCorrectResult() throws GeneralSecurityException {
+        byte[] secret = base16().decode("0123456789ABCDEF0123456789ABCDEF");
+        byte[] nonce = base16().decode("0001020304050607");
+
+        // "Someone's Google Headphone".getBytes(UTF_8) is
+        // base16().decode("536F6D656F6E65277320476F6F676C65204865616470686F6E65");
+        byte[] encryptedData =
+                AesCtrMultipleBlockEncryption.doAesCtr(
+                        secret,
+                        "Someone's Google Headphone".getBytes(UTF_8),
+                        nonce);
+
+        assertThat(encryptedData)
+                .isEqualTo(base16().decode("EE4A2483738052E44E9B2A145E5DDFAA44B9E5536AF438E1E5C6"));
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AesEcbSingleBlockEncryptionTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AesEcbSingleBlockEncryptionTest.java
new file mode 100644
index 0000000..eccbd01
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/AesEcbSingleBlockEncryptionTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link AesEcbSingleBlockEncryption}. */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class AesEcbSingleBlockEncryptionTest {
+
+    private static final byte[] PLAINTEXT = base16().decode("F30F4E786C59A7BBF3873B5A49BA97EA");
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void encryptDecryptSuccessful() throws Exception {
+        byte[] secret = AesEcbSingleBlockEncryption.generateKey();
+        byte[] encrypted = AesEcbSingleBlockEncryption.encrypt(secret, PLAINTEXT);
+        assertThat(encrypted).isNotEqualTo(PLAINTEXT);
+        byte[] decrypted = AesEcbSingleBlockEncryption.decrypt(secret, encrypted);
+        assertThat(decrypted).isEqualTo(PLAINTEXT);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void encryptionSizeLimitationEnforced() throws Exception {
+        byte[] secret = AesEcbSingleBlockEncryption.generateKey();
+        byte[] largePacket = Bytes.concat(PLAINTEXT, PLAINTEXT);
+        AesEcbSingleBlockEncryption.encrypt(secret, largePacket);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void decryptionSizeLimitationEnforced() throws Exception {
+        byte[] secret = AesEcbSingleBlockEncryption.generateKey();
+        byte[] largePacket = Bytes.concat(PLAINTEXT, PLAINTEXT);
+        AesEcbSingleBlockEncryption.decrypt(secret, largePacket);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAddressTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAddressTest.java
new file mode 100644
index 0000000..6c95558
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAddressTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.bluetooth.BluetoothAdapter;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link BluetoothAddress}. */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class BluetoothAddressTest {
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void maskBluetoothAddress_whenInputIsNull() {
+        assertThat(BluetoothAddress.maskBluetoothAddress(null)).isEqualTo("");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void maskBluetoothAddress_whenInputStringNotMatchFormat() {
+        assertThat(BluetoothAddress.maskBluetoothAddress("AA:BB:CC")).isEqualTo("AA:BB:CC");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void maskBluetoothAddress_whenInputStringMatchFormat() {
+        assertThat(BluetoothAddress.maskBluetoothAddress("AA:BB:CC:DD:EE:FF"))
+                .isEqualTo("XX:XX:XX:XX:EE:FF");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void maskBluetoothAddress_whenInputStringContainLowerCaseMatchFormat() {
+        assertThat(BluetoothAddress.maskBluetoothAddress("Aa:Bb:cC:dD:eE:Ff"))
+                .isEqualTo("XX:XX:XX:XX:EE:FF");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void maskBluetoothAddress_whenInputBluetoothDevice() {
+        assertThat(
+                BluetoothAddress.maskBluetoothAddress(
+                        BluetoothAdapter.getDefaultAdapter().getRemoteDevice("FF:EE:DD:CC:BB:AA")))
+                .isEqualTo("XX:XX:XX:XX:BB:AA");
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairerTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairerTest.java
new file mode 100644
index 0000000..0a56f2f
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairerTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.google.common.collect.Iterables;
+
+import junit.framework.TestCase;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+/** Unit tests for {@link BluetoothAudioPairer}. */
+@Presubmit
+@SmallTest
+public class BluetoothAudioPairerTest extends TestCase {
+
+    private static final byte[] SECRET = new byte[]{3, 0};
+    private static final boolean PRIVATE_INITIAL_PAIRING = false;
+    private static final String EVENT_NAME = "EVENT_NAME";
+    private static final BluetoothDevice BLUETOOTH_DEVICE = BluetoothAdapter.getDefaultAdapter()
+            .getRemoteDevice("11:22:33:44:55:66");
+    private static final int BOND_TIMEOUT_SECONDS = 1;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        initMocks(this);
+        BluetoothAudioPairer.enableTestMode();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testKeyBasedPairingInfoConstructor() {
+        assertThat(new BluetoothAudioPairer.KeyBasedPairingInfo(
+                SECRET,
+                null /* GattConnectionManager */,
+                PRIVATE_INITIAL_PAIRING)).isNotNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothAudioPairerConstructor() {
+        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        try {
+            assertThat(new BluetoothAudioPairer(
+                    context,
+                    BLUETOOTH_DEVICE,
+                    Preferences.builder().build(),
+                    new EventLoggerWrapper(new TestEventLogger()),
+                    null /* KeyBasePairingInfo */,
+                    null /*PasskeyConfirmationHandler */,
+                    new TimingLogger(EVENT_NAME, Preferences.builder().build()))).isNotNull();
+        } catch (PairingException e) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothAudioPairerUnpairNoCrash() {
+        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        try {
+            new BluetoothAudioPairer(
+                    context,
+                    BLUETOOTH_DEVICE,
+                    Preferences.builder().build(),
+                    new EventLoggerWrapper(new TestEventLogger()),
+                    null /* KeyBasePairingInfo */,
+                    null /*PasskeyConfirmationHandler */,
+                    new TimingLogger(EVENT_NAME, Preferences.builder().build())).unpair();
+        } catch (PairingException | InterruptedException | ExecutionException
+                | TimeoutException e) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothAudioPairerPairNoCrash() {
+        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        try {
+            new BluetoothAudioPairer(
+                    context,
+                    BLUETOOTH_DEVICE,
+                    Preferences.builder().setCreateBondTimeoutSeconds(BOND_TIMEOUT_SECONDS).build(),
+                    new EventLoggerWrapper(new TestEventLogger()),
+                    null /* KeyBasePairingInfo */,
+                    null /*PasskeyConfirmationHandler */,
+                    new TimingLogger(EVENT_NAME, Preferences.builder().build())).pair();
+        } catch (PairingException | InterruptedException | ExecutionException
+                | TimeoutException e) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothAudioPairerConnectNoCrash() {
+        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        try {
+            new BluetoothAudioPairer(
+                    context,
+                    BLUETOOTH_DEVICE,
+                    Preferences.builder().setCreateBondTimeoutSeconds(BOND_TIMEOUT_SECONDS).build(),
+                    new EventLoggerWrapper(new TestEventLogger()),
+                    null /* KeyBasePairingInfo */,
+                    null /*PasskeyConfirmationHandler */,
+                    new TimingLogger(EVENT_NAME, Preferences.builder().build()))
+                    .connect(Constants.A2DP_SINK_SERVICE_UUID, true /* enable pairing behavior */);
+        } catch (PairingException | InterruptedException | ExecutionException
+                | TimeoutException | ReflectionException e) {
+        }
+    }
+
+    static class TestEventLogger implements EventLogger {
+
+        private List<Item> mLogs = new ArrayList<>();
+
+        @Override
+        public void logEventSucceeded(Event event) {
+            mLogs.add(new Item(event));
+        }
+
+        @Override
+        public void logEventFailed(Event event, Exception e) {
+            mLogs.add(new ItemFailed(event, e));
+        }
+
+        List<Item> getErrorLogs() {
+            return mLogs.stream().filter(item -> item instanceof ItemFailed)
+                    .collect(Collectors.toList());
+        }
+
+        List<Item> getLogs() {
+            return mLogs;
+        }
+
+        List<Item> getLast() {
+            return mLogs.subList(mLogs.size() - 1, mLogs.size());
+        }
+
+        BluetoothDevice getDevice() {
+            return Iterables.getLast(mLogs).mEvent.getBluetoothDevice();
+        }
+
+        public static class Item {
+
+            final Event mEvent;
+
+            Item(Event event) {
+                this.mEvent = event;
+            }
+
+            @Override
+            public String toString() {
+                return "Item{" + "event=" + mEvent + '}';
+            }
+        }
+
+        public static class ItemFailed extends Item {
+
+            final Exception mException;
+
+            ItemFailed(Event event, Exception e) {
+                super(event);
+                this.mException = e;
+            }
+
+            @Override
+            public String toString() {
+                return "ItemFailed{" + "event=" + mEvent + ", exception=" + mException + '}';
+            }
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothUuidsTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothUuidsTest.java
new file mode 100644
index 0000000..fa977ed
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothUuidsTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.UUID;
+
+/** Unit tests for {@link BluetoothUuids}. */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class BluetoothUuidsTest {
+
+    // According to {@code android.bluetooth.BluetoothUuid}
+    private static final short A2DP_SINK_SHORT_UUID = (short) 0x110B;
+    private static final UUID A2DP_SINK_CHARACTERISTICS =
+            UUID.fromString("0000110B-0000-1000-8000-00805F9B34FB");
+
+    // According to {go/fastpair-128bit-gatt}, the short uuid locates at the 3rd and 4th bytes based
+    // on the Fast Pair custom GATT characteristics 128-bit UUIDs base -
+    // "FE2C0000-8366-4814-8EB0-01DE32100BEA".
+    private static final short CUSTOM_SHORT_UUID = (short) 0x9487;
+    private static final UUID CUSTOM_CHARACTERISTICS =
+            UUID.fromString("FE2C9487-8366-4814-8EB0-01DE32100BEA");
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void get16BitUuid() {
+        assertThat(BluetoothUuids.get16BitUuid(A2DP_SINK_CHARACTERISTICS))
+                .isEqualTo(A2DP_SINK_SHORT_UUID);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void is16BitUuid() {
+        assertThat(BluetoothUuids.is16BitUuid(A2DP_SINK_CHARACTERISTICS)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void to128BitUuid() {
+        assertThat(BluetoothUuids.to128BitUuid(A2DP_SINK_SHORT_UUID))
+                .isEqualTo(A2DP_SINK_CHARACTERISTICS);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void toFastPair128BitUuid() {
+        assertThat(BluetoothUuids.toFastPair128BitUuid(CUSTOM_SHORT_UUID))
+                .isEqualTo(CUSTOM_CHARACTERISTICS);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/ConstantsTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/ConstantsTest.java
new file mode 100644
index 0000000..f7ffa24
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/ConstantsTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.to128BitUuid;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.toFastPair128BitUuid;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+
+import androidx.test.filters.SdkSuppress;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection;
+
+import junit.framework.TestCase;
+
+import org.mockito.Mock;
+
+import java.util.UUID;
+
+/**
+ * Unit tests for {@link Constants}.
+ */
+public class ConstantsTest extends TestCase {
+
+    @Mock
+    private BluetoothGattConnection mMockGattConnection;
+
+    private static final UUID OLD_KEY_BASE_PAIRING_CHARACTERISTICS = to128BitUuid((short) 0x1234);
+
+    private static final UUID NEW_KEY_BASE_PAIRING_CHARACTERISTICS =
+            toFastPair128BitUuid((short) 0x1234);
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        initMocks(this);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getId_whenSupportNewCharacteristics() throws BluetoothException {
+        when(mMockGattConnection.getCharacteristic(any(UUID.class), any(UUID.class)))
+                .thenReturn(new BluetoothGattCharacteristic(NEW_KEY_BASE_PAIRING_CHARACTERISTICS, 0,
+                        0));
+
+        assertThat(KeyBasedPairingCharacteristic.getId(mMockGattConnection))
+                .isEqualTo(NEW_KEY_BASE_PAIRING_CHARACTERISTICS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getId_whenNotSupportNewCharacteristics() throws BluetoothException {
+        // {@link BluetoothGattConnection#getCharacteristic(UUID, UUID)} throws {@link
+        // BluetoothException} if the characteristic not found .
+        when(mMockGattConnection.getCharacteristic(any(UUID.class), any(UUID.class)))
+                .thenThrow(new BluetoothException(""));
+
+        assertThat(KeyBasedPairingCharacteristic.getId(mMockGattConnection))
+                .isEqualTo(OLD_KEY_BASE_PAIRING_CHARACTERISTICS);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/EllipticCurveDiffieHellmanExchangeTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/EllipticCurveDiffieHellmanExchangeTest.java
new file mode 100644
index 0000000..3719783
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/EllipticCurveDiffieHellmanExchangeTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.io.BaseEncoding.base64;
+import static com.google.common.primitives.Bytes.concat;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Unit tests for {@link EllipticCurveDiffieHellmanExchange}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class EllipticCurveDiffieHellmanExchangeTest {
+
+    public static final byte[] ANTI_SPOOF_PUBLIC_KEY = base64().decode(
+            "d2JTfvfdS6u7LmGfMOmco3C7ra3lW1k17AOly0LrBydDZURacfTYIMmo5K1ejfD9e8b6qHs"
+                    + "DTNzselhifi10kQ==");
+    public static final byte[] ANTI_SPOOF_PRIVATE_KEY =
+            base64().decode("Rn9GbLRPQTFc2O7WFVGkydzcUS9Tuj7R9rLh6EpLtuU=");
+
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void generateCommonKey() throws Exception {
+        EllipticCurveDiffieHellmanExchange bob = EllipticCurveDiffieHellmanExchange.create();
+        EllipticCurveDiffieHellmanExchange alice = EllipticCurveDiffieHellmanExchange.create();
+
+        assertThat(bob.getPublicKey()).isNotEqualTo(alice.getPublicKey());
+        assertThat(bob.getPrivateKey()).isNotEqualTo(alice.getPrivateKey());
+
+        assertThat(bob.generateSecret(alice.getPublicKey()))
+                .isEqualTo(alice.generateSecret(bob.getPublicKey()));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void generateCommonKey_withExistingPrivateKey() throws Exception {
+        EllipticCurveDiffieHellmanExchange bob = EllipticCurveDiffieHellmanExchange.create();
+        EllipticCurveDiffieHellmanExchange alice =
+                EllipticCurveDiffieHellmanExchange.create(ANTI_SPOOF_PRIVATE_KEY);
+
+        assertThat(alice.generateSecret(bob.getPublicKey()))
+                .isEqualTo(bob.generateSecret(ANTI_SPOOF_PUBLIC_KEY));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void generateCommonKey_soundcoreAntiSpoofingKey_generatedTooShort() throws Exception {
+        // This soundcore device has a public key that was generated which starts with 0x0. This was
+        // stripped out in our database, but this test confirms that adding that byte back fixes the
+        // issue and allows the generated secrets to match each other.
+        byte[] soundCorePublicKey = concat(new byte[]{0}, base64().decode(
+                "EYapuIsyw/nwHAdMxr12FCtAi4gY3EtuW06JuKDg4SA76IoIDVeol2vsGKy0Ea2Z00"
+                        + "ArOTiBDsk0L+4Xo9AA"));
+        byte[] soundCorePrivateKey = base64()
+                .decode("lW5idsrfX7cBC8kO/kKn3w3GXirqt9KnJoqXUcOMhjM=");
+        EllipticCurveDiffieHellmanExchange bob = EllipticCurveDiffieHellmanExchange.create();
+        EllipticCurveDiffieHellmanExchange alice =
+                EllipticCurveDiffieHellmanExchange.create(soundCorePrivateKey);
+
+        assertThat(alice.generateSecret(bob.getPublicKey()))
+                .isEqualTo(bob.generateSecret(soundCorePublicKey));
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/EventTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/EventTest.java
new file mode 100644
index 0000000..28e925f
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/EventTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.os.Parcel;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Unit tests for {@link Event}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class EventTest {
+
+    private static final String EXCEPTION_MESSAGE = "Test exception";
+    private static final long TIMESTAMP = 1234L;
+    private static final @EventCode int EVENT_CODE = EventCode.CREATE_BOND;
+    private static final BluetoothDevice BLUETOOTH_DEVICE = BluetoothAdapter.getDefaultAdapter()
+            .getRemoteDevice("11:22:33:44:55:66");
+    private static final Short PROFILE = (short) 1;
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void createAndReadFromParcel() {
+        Event event =
+                Event.builder()
+                        .setException(new Exception(EXCEPTION_MESSAGE))
+                        .setTimestamp(TIMESTAMP)
+                        .setEventCode(EVENT_CODE)
+                        .setBluetoothDevice(BLUETOOTH_DEVICE)
+                        .setProfile(PROFILE)
+                        .build();
+
+        Parcel parcel = Parcel.obtain();
+        event.writeToParcel(parcel, event.describeContents());
+        parcel.setDataPosition(0);
+        Event result = Event.CREATOR.createFromParcel(parcel);
+
+        assertThat(result.getException()).hasMessageThat()
+                .isEqualTo(event.getException().getMessage());
+        assertThat(result.getTimestamp()).isEqualTo(event.getTimestamp());
+        assertThat(result.getEventCode()).isEqualTo(event.getEventCode());
+        assertThat(result.getBluetoothDevice()).isEqualTo(event.getBluetoothDevice());
+        assertThat(result.getProfile()).isEqualTo(event.getProfile());
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnectionTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnectionTest.java
new file mode 100644
index 0000000..a103a72
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnectionTest.java
@@ -0,0 +1,370 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.FastPairDualConnection.GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED;
+import static com.android.server.nearby.common.bluetooth.fastpair.FastPairDualConnection.GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST;
+import static com.android.server.nearby.common.bluetooth.fastpair.FastPairDualConnection.GATT_ERROR_CODE_TIMEOUT;
+import static com.android.server.nearby.common.bluetooth.fastpair.FastPairDualConnection.appendMoreErrorCode;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyShort;
+import static org.mockito.Mockito.doNothing;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.protobuf.ByteString;
+
+import junit.framework.TestCase;
+
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+/**
+ * Unit tests for {@link FastPairDualConnection}.
+ */
+@Presubmit
+@SmallTest
+public class FastPairDualConnectionTest extends TestCase {
+
+    private static final String BLE_ADDRESS = "00:11:22:33:FF:EE";
+    private static final String MASKED_BLE_ADDRESS = "MASKED_BLE_ADDRESS";
+    private static final short[] PROFILES = {Constants.A2DP_SINK_SERVICE_UUID};
+    private static final int NUM_CONNECTION_ATTEMPTS = 1;
+    private static final boolean ENABLE_PAIRING_BEHAVIOR = true;
+    private static final BluetoothDevice BLUETOOTH_DEVICE = BluetoothAdapter.getDefaultAdapter()
+            .getRemoteDevice("11:22:33:44:55:66");
+    private static final String DEVICE_NAME = "DEVICE_NAME";
+    private static final byte[] ACCOUNT_KEY = new byte[]{1, 3};
+    private static final byte[] HASH_VALUE = new byte[]{7};
+
+    private TestEventLogger mEventLogger;
+    @Mock private TimingLogger mTimingLogger;
+    @Mock private BluetoothAudioPairer mBluetoothAudioPairer;
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        BluetoothAudioPairer.enableTestMode();
+        FastPairDualConnection.enableTestMode();
+        MockitoAnnotations.initMocks(this);
+
+        doNothing().when(mBluetoothAudioPairer).connect(anyShort(), anyBoolean());
+        mEventLogger = new TestEventLogger();
+    }
+
+    private FastPairDualConnection newFastPairDualConnection(
+            String bleAddress, Preferences.Builder prefsBuilder) {
+        return new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                bleAddress,
+                prefsBuilder.build(),
+                mEventLogger,
+                mTimingLogger);
+    }
+
+    private FastPairDualConnection newFastPairDualConnection2(
+            String bleAddress, Preferences.Builder prefsBuilder) {
+        return new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                bleAddress,
+                prefsBuilder.build(),
+                mEventLogger);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testFastPairDualConnectionConstructor() {
+        assertThat(newFastPairDualConnection(BLE_ADDRESS, Preferences.builder())).isNotNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testFastPairDualConnectionConstructor2() {
+        assertThat(newFastPairDualConnection2(BLE_ADDRESS, Preferences.builder())).isNotNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAttemptConnectProfiles() {
+        try {
+            new FastPairDualConnection(
+                    ApplicationProvider.getApplicationContext(),
+                    BLE_ADDRESS,
+                    Preferences.builder().build(),
+                    mEventLogger,
+                    mTimingLogger)
+                    .attemptConnectProfiles(
+                            mBluetoothAudioPairer,
+                            MASKED_BLE_ADDRESS,
+                            PROFILES,
+                            NUM_CONNECTION_ATTEMPTS,
+                            ENABLE_PAIRING_BEHAVIOR);
+        } catch (PairingException e) {
+            // Mocked pair doesn't throw Pairing Exception.
+        }
+    }
+
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAppendMoreErrorCode_gattError() {
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED,
+                        new BluetoothGattException("Test", 133)))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED + 133);
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED,
+                        new BluetoothGattException("Test", 257)))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED + 257);
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED, new BluetoothException("Test")))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED);
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED,
+                        new BluetoothOperationTimeoutException("Test")))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED + GATT_ERROR_CODE_TIMEOUT);
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST,
+                        new BluetoothGattException("Test", 41)))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST + 41);
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST,
+                        new BluetoothGattException("Test", 788)))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST + 788);
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST, new BluetoothException("Test")))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST);
+        assertThat(
+                appendMoreErrorCode(
+                        GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST,
+                        new BluetoothOperationTimeoutException("Test")))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST + GATT_ERROR_CODE_TIMEOUT);
+        assertThat(appendMoreErrorCode(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST, /* cause= */ null))
+                .isEqualTo(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testUnpairNotCrash() {
+        try {
+            new FastPairDualConnection(
+                    ApplicationProvider.getApplicationContext(),
+                    BLE_ADDRESS,
+                    Preferences.builder().build(),
+                    mEventLogger,
+                    mTimingLogger).unpair(BLUETOOTH_DEVICE);
+        } catch (ExecutionException | InterruptedException | ReflectionException
+                | TimeoutException | PairingException e) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetFastPairHistory() {
+        new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().build(),
+                mEventLogger,
+                mTimingLogger).setFastPairHistory(ImmutableList.of());
+    }
+
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetGetProviderDeviceName() {
+        FastPairDualConnection connection = new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().build(),
+                mEventLogger,
+                mTimingLogger);
+        connection.setProviderDeviceName(DEVICE_NAME);
+        connection.getProviderDeviceName();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetExistingAccountKey() {
+        FastPairDualConnection connection = new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().build(),
+                mEventLogger,
+                mTimingLogger);
+        connection.getExistingAccountKey();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testPair() {
+        FastPairDualConnection connection = new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().setNumSdpAttempts(0)
+                        .setLogPairWithCachedModelId(false).build(),
+                mEventLogger,
+                mTimingLogger);
+        try {
+            connection.pair();
+        } catch (BluetoothException | InterruptedException | ReflectionException
+                | ExecutionException | TimeoutException | PairingException e) {
+        }
+    }
+
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetPublicAddress() {
+        FastPairDualConnection connection = new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().setNumSdpAttempts(0)
+                        .setLogPairWithCachedModelId(false).build(),
+                mEventLogger,
+                mTimingLogger);
+        connection.getPublicAddress();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testShouldWriteAccountKeyForExistingCase() {
+        FastPairDualConnection connection = new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().setNumSdpAttempts(0)
+                        .setLogPairWithCachedModelId(false).build(),
+                mEventLogger,
+                mTimingLogger);
+        connection.shouldWriteAccountKeyForExistingCase(ACCOUNT_KEY);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testReadFirmwareVersion() {
+        FastPairDualConnection connection = new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().setNumSdpAttempts(0)
+                        .setLogPairWithCachedModelId(false).build(),
+                mEventLogger,
+                mTimingLogger);
+        try {
+            connection.readFirmwareVersion();
+        } catch (BluetoothException | InterruptedException | ExecutionException
+                | TimeoutException e) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHistoryItem() {
+        FastPairDualConnection connection = new FastPairDualConnection(
+                ApplicationProvider.getApplicationContext(),
+                BLE_ADDRESS,
+                Preferences.builder().setNumSdpAttempts(0)
+                        .setLogPairWithCachedModelId(false).build(),
+                mEventLogger,
+                mTimingLogger);
+        ImmutableList.Builder<FastPairHistoryItem> historyBuilder = ImmutableList.builder();
+        FastPairHistoryItem historyItem1 =
+                FastPairHistoryItem.create(
+                        ByteString.copyFrom(ACCOUNT_KEY), ByteString.copyFrom(HASH_VALUE));
+        historyBuilder.add(historyItem1);
+
+        connection.setFastPairHistory(historyBuilder.build());
+        assertThat(connection.mPairedHistoryFinder.isInPairedHistory("11:22:33:44:55:88"))
+                .isFalse();
+    }
+
+    static class TestEventLogger implements EventLogger {
+
+        private List<Item> mLogs = new ArrayList<>();
+
+        @Override
+        public void logEventSucceeded(Event event) {
+            mLogs.add(new Item(event));
+        }
+
+        @Override
+        public void logEventFailed(Event event, Exception e) {
+            mLogs.add(new ItemFailed(event, e));
+        }
+
+        List<Item> getErrorLogs() {
+            return mLogs.stream().filter(item -> item instanceof ItemFailed)
+                    .collect(Collectors.toList());
+        }
+
+        List<Item> getLogs() {
+            return mLogs;
+        }
+
+        List<Item> getLast() {
+            return mLogs.subList(mLogs.size() - 1, mLogs.size());
+        }
+
+        BluetoothDevice getDevice() {
+            return Iterables.getLast(mLogs).mEvent.getBluetoothDevice();
+        }
+
+        public static class Item {
+
+            final Event mEvent;
+
+            Item(Event event) {
+                this.mEvent = event;
+            }
+
+            @Override
+            public String toString() {
+                return "Item{" + "event=" + mEvent + '}';
+            }
+        }
+
+        public static class ItemFailed extends Item {
+
+            final Exception mException;
+
+            ItemFailed(Event event, Exception e) {
+                super(event);
+                this.mException = e;
+            }
+
+            @Override
+            public String toString() {
+                return "ItemFailed{" + "event=" + mEvent + ", exception=" + mException + '}';
+            }
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/FastPairHistoryItemTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/FastPairHistoryItemTest.java
new file mode 100644
index 0000000..b47fd89
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/FastPairHistoryItemTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.primitives.Bytes.concat;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.hash.Hashing;
+import com.google.protobuf.ByteString;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Unit tests for {@link FastPairHistoryItem}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class FastPairHistoryItemTest {
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputMatchedPublicAddress_isMatchedReturnTrue() {
+        final byte[] accountKey = base16().decode("0123456789ABCDEF");
+        final byte[] publicAddress = BluetoothAddress.decode("11:22:33:44:55:66");
+        final byte[] hashValue =
+                Hashing.sha256().hashBytes(concat(accountKey, publicAddress)).asBytes();
+
+        FastPairHistoryItem historyItem =
+                FastPairHistoryItem
+                        .create(ByteString.copyFrom(accountKey), ByteString.copyFrom(hashValue));
+
+        assertThat(historyItem.isMatched(publicAddress)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputNotMatchedPublicAddress_isMatchedReturnFalse() {
+        final byte[] accountKey = base16().decode("0123456789ABCDEF");
+        final byte[] publicAddress1 = BluetoothAddress.decode("11:22:33:44:55:66");
+        final byte[] publicAddress2 = BluetoothAddress.decode("11:22:33:44:55:77");
+        final byte[] hashValue =
+                Hashing.sha256().hashBytes(concat(accountKey, publicAddress1)).asBytes();
+
+        FastPairHistoryItem historyItem =
+                FastPairHistoryItem
+                        .create(ByteString.copyFrom(accountKey), ByteString.copyFrom(hashValue));
+
+        assertThat(historyItem.isMatched(publicAddress2)).isFalse();
+    }
+}
+
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManagerTest.java
new file mode 100644
index 0000000..2f80a30
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManagerTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+
+import com.google.common.collect.ImmutableSet;
+
+import junit.framework.TestCase;
+
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Unit tests for {@link GattConnectionManager}.
+ */
+@Presubmit
+@SmallTest
+public class GattConnectionManagerTest extends TestCase {
+
+    private static final String FAST_PAIR_ADDRESS = "BB:BB:BB:BB:BB:1E";
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        GattConnectionManager.enableTestMode();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattConnectionManagerConstructor() throws Exception {
+        GattConnectionManager manager = createManager(Preferences.builder());
+        try {
+            manager.getConnection();
+        } catch (ExecutionException e) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIsNoRetryError() {
+        Preferences preferences =
+                Preferences.builder()
+                        .setGattConnectionAndSecretHandshakeNoRetryGattError(
+                                ImmutableSet.of(257, 999))
+                        .build();
+
+        assertThat(
+                GattConnectionManager.isNoRetryError(
+                        preferences, new BluetoothGattException("Test", 133)))
+                .isFalse();
+        assertThat(
+                GattConnectionManager.isNoRetryError(
+                        preferences, new BluetoothGattException("Test", 257)))
+                .isTrue();
+        assertThat(
+                GattConnectionManager.isNoRetryError(
+                        preferences, new BluetoothGattException("Test", 999)))
+                .isTrue();
+        assertThat(GattConnectionManager.isNoRetryError(
+                preferences, new BluetoothException("Test")))
+                .isFalse();
+        assertThat(
+                GattConnectionManager.isNoRetryError(
+                        preferences, new BluetoothOperationTimeoutException("Test")))
+                .isFalse();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetTimeoutNotOverShortRetryMaxSpentTimeGetShort() {
+        Preferences preferences = Preferences.builder().build();
+
+        assertThat(
+                createManager(Preferences.builder(), () -> {})
+                        .getTimeoutMs(
+                                preferences.getGattConnectShortTimeoutRetryMaxSpentTimeMs() - 1))
+                .isEqualTo(preferences.getGattConnectShortTimeoutMs());
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetTimeoutOverShortRetryMaxSpentTimeGetLong() {
+        Preferences preferences = Preferences.builder().build();
+
+        assertThat(
+                createManager(Preferences.builder(), () -> {})
+                        .getTimeoutMs(
+                                preferences.getGattConnectShortTimeoutRetryMaxSpentTimeMs() + 1))
+                .isEqualTo(preferences.getGattConnectLongTimeoutMs());
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetTimeoutRetryNotEnabledGetOrigin() {
+        Preferences preferences = Preferences.builder().build();
+
+        assertThat(
+                createManager(
+                        Preferences.builder().setRetryGattConnectionAndSecretHandshake(false),
+                        () -> {})
+                        .getTimeoutMs(0))
+                .isEqualTo(Duration.ofSeconds(
+                        preferences.getGattConnectionTimeoutSeconds()).toMillis());
+    }
+
+    private GattConnectionManager createManager(Preferences.Builder prefs) {
+        return createManager(prefs, () -> {});
+    }
+
+    private GattConnectionManager createManager(
+            Preferences.Builder prefs, ToggleBluetoothTask toggleBluetooth) {
+        return createManager(prefs, toggleBluetooth,
+                /* fastPairSignalChecker= */ null);
+    }
+
+    private GattConnectionManager createManager(
+            Preferences.Builder prefs,
+            ToggleBluetoothTask toggleBluetooth,
+            @Nullable FastPairConnection.FastPairSignalChecker fastPairSignalChecker) {
+        return new GattConnectionManager(
+                ApplicationProvider.getApplicationContext(),
+                prefs.build(),
+                new EventLoggerWrapper(null),
+                BluetoothAdapter.getDefaultAdapter(),
+                toggleBluetooth,
+                FAST_PAIR_ADDRESS,
+                new TimingLogger("GattConnectionManager", prefs.build()),
+                fastPairSignalChecker,
+                /* setMtu= */ false);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/HeadsetPieceTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/HeadsetPieceTest.java
new file mode 100644
index 0000000..670b2ca
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/HeadsetPieceTest.java
@@ -0,0 +1,200 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Unit tests for {@link HeadsetPiece}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class HeadsetPieceTest {
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void parcelAndUnparcel() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+        Parcel expectedParcel = Parcel.obtain();
+        headsetPiece.writeToParcel(expectedParcel, 0);
+        expectedParcel.setDataPosition(0);
+
+        HeadsetPiece fromParcel = HeadsetPiece.CREATOR.createFromParcel(expectedParcel);
+
+        assertThat(fromParcel).isEqualTo(headsetPiece);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void parcelAndUnparcel_nullImageContentUri() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().setImageContentUri(null).build();
+        Parcel expectedParcel = Parcel.obtain();
+        headsetPiece.writeToParcel(expectedParcel, 0);
+        expectedParcel.setDataPosition(0);
+
+        HeadsetPiece fromParcel = HeadsetPiece.CREATOR.createFromParcel(expectedParcel);
+
+        assertThat(fromParcel).isEqualTo(headsetPiece);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void equals() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+
+        HeadsetPiece compareTo = createDefaultHeadset().build();
+
+        assertThat(headsetPiece).isEqualTo(compareTo);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void equals_nullImageContentUri() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().setImageContentUri(null).build();
+
+        HeadsetPiece compareTo = createDefaultHeadset().setImageContentUri(null).build();
+
+        assertThat(headsetPiece).isEqualTo(compareTo);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void notEquals_differentLowLevelThreshold() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+
+        HeadsetPiece compareTo = createDefaultHeadset().setLowLevelThreshold(1).build();
+
+        assertThat(headsetPiece).isNotEqualTo(compareTo);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void notEquals_differentBatteryLevel() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+
+        HeadsetPiece compareTo = createDefaultHeadset().setBatteryLevel(99).build();
+
+        assertThat(headsetPiece).isNotEqualTo(compareTo);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void notEquals_differentImageUrl() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+
+        HeadsetPiece compareTo =
+                createDefaultHeadset().setImageUrl("http://fake.image.path/different.png").build();
+
+        assertThat(headsetPiece).isNotEqualTo(compareTo);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void notEquals_differentChargingState() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+
+        HeadsetPiece compareTo = createDefaultHeadset().setCharging(false).build();
+
+        assertThat(headsetPiece).isNotEqualTo(compareTo);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void notEquals_differentImageContentUri() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+
+        HeadsetPiece compareTo =
+                createDefaultHeadset().setImageContentUri(Uri.parse("content://different.png"))
+                        .build();
+
+        assertThat(headsetPiece).isNotEqualTo(compareTo);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void notEquals_nullImageContentUri() {
+        HeadsetPiece headsetPiece = createDefaultHeadset().build();
+
+        HeadsetPiece compareTo = createDefaultHeadset().setImageContentUri(null).build();
+
+        assertThat(headsetPiece).isNotEqualTo(compareTo);
+    }
+
+    private static HeadsetPiece.Builder createDefaultHeadset() {
+        return HeadsetPiece.builder()
+                .setLowLevelThreshold(30)
+                .setBatteryLevel(18)
+                .setImageUrl("http://fake.image.path/image.png")
+                .setImageContentUri(Uri.parse("content://image.png"))
+                .setCharging(true);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void isLowBattery() {
+        HeadsetPiece headsetPiece =
+                HeadsetPiece.builder()
+                        .setLowLevelThreshold(30)
+                        .setBatteryLevel(18)
+                        .setImageUrl("http://fake.image.path/image.png")
+                        .setCharging(false)
+                        .build();
+
+        assertThat(headsetPiece.isBatteryLow()).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void isNotLowBattery() {
+        HeadsetPiece headsetPiece =
+                HeadsetPiece.builder()
+                        .setLowLevelThreshold(30)
+                        .setBatteryLevel(31)
+                        .setImageUrl("http://fake.image.path/image.png")
+                        .setCharging(false)
+                        .build();
+
+        assertThat(headsetPiece.isBatteryLow()).isFalse();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void isNotLowBattery_whileCharging() {
+        HeadsetPiece headsetPiece =
+                HeadsetPiece.builder()
+                        .setLowLevelThreshold(30)
+                        .setBatteryLevel(18)
+                        .setImageUrl("http://fake.image.path/image.png")
+                        .setCharging(true)
+                        .build();
+
+        assertThat(headsetPiece.isBatteryLow()).isFalse();
+    }
+}
+
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/HmacSha256Test.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/HmacSha256Test.java
new file mode 100644
index 0000000..8db3b97
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/HmacSha256Test.java
@@ -0,0 +1,153 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.KEY_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.HmacSha256.HMAC_SHA256_BLOCK_SIZE;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.primitives.Bytes.concat;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.base.Preconditions;
+import com.google.common.hash.Hashing;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.Random;
+
+/**
+ * Unit tests for {@link HmacSha256}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class HmacSha256Test {
+
+    private static final int EXTRACT_HMAC_SIZE = 8;
+    private static final byte OUTER_PADDING_BYTE = 0x5c;
+    private static final byte INNER_PADDING_BYTE = 0x36;
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void compareResultWithOurImplementation_mustBeIdentical()
+            throws GeneralSecurityException {
+        Random random = new Random(0xFE2C);
+
+        for (int i = 0; i < 1000; i++) {
+            byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+            // Avoid too small data size that may cause false alarm.
+            int dataLength = random.nextInt(64);
+            byte[] data = new byte[dataLength];
+            random.nextBytes(data);
+
+            assertThat(HmacSha256.build(secret, data)).isEqualTo(doHmacSha256(secret, data));
+        }
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectKeySizeToDecrypt_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH - 1];
+        byte[] data = base16().decode("1234567890ABCDEF1234567890ABCDEF1234567890ABCD");
+
+        GeneralSecurityException exception =
+                assertThrows(GeneralSecurityException.class, () -> HmacSha256.build(secret, data));
+
+        assertThat(exception)
+                .hasMessageThat()
+                .contains("Incorrect key length, should be the AES-128 key.");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTwoIdenticalArrays_compareTwoHmacMustReturnTrue() {
+        Random random = new Random(0x1237);
+        byte[] array1 = new byte[EXTRACT_HMAC_SIZE];
+        random.nextBytes(array1);
+        byte[] array2 = Arrays.copyOf(array1, array1.length);
+
+        assertThat(HmacSha256.compareTwoHMACs(array1, array2)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTwoRandomArrays_compareTwoHmacMustReturnFalse() {
+        Random random = new Random(0xff);
+        byte[] array1 = new byte[EXTRACT_HMAC_SIZE];
+        random.nextBytes(array1);
+        byte[] array2 = new byte[EXTRACT_HMAC_SIZE];
+        random.nextBytes(array2);
+
+        assertThat(HmacSha256.compareTwoHMACs(array1, array2)).isFalse();
+    }
+
+    // HMAC-SHA256 may not be previously defined on Bluetooth platforms, so we explicitly create
+    // the code on test case. This will allow us to easily detect where partner implementation might
+    // have gone wrong or where our spec isn't clear enough.
+    static byte[] doHmacSha256(byte[] key, byte[] data) {
+
+        Preconditions.checkArgument(
+                key != null && key.length == KEY_LENGTH && data != null,
+                "Parameters can't be null.");
+
+        // Performs SHA256(concat((key ^ opad),SHA256(concat((key ^ ipad), data)))), where
+        // key is the given secret extended to 64 bytes by concat(secret, ZEROS).
+        // opad is 64 bytes outer padding, consisting of repeated bytes valued 0x5c.
+        // ipad is 64 bytes inner padding, consisting of repeated bytes valued 0x36.
+        byte[] keyIpad = new byte[HMAC_SHA256_BLOCK_SIZE];
+        byte[] keyOpad = new byte[HMAC_SHA256_BLOCK_SIZE];
+
+        for (int i = 0; i < KEY_LENGTH; i++) {
+            keyIpad[i] = (byte) (key[i] ^ INNER_PADDING_BYTE);
+            keyOpad[i] = (byte) (key[i] ^ OUTER_PADDING_BYTE);
+        }
+        Arrays.fill(keyIpad, KEY_LENGTH, HMAC_SHA256_BLOCK_SIZE, INNER_PADDING_BYTE);
+        Arrays.fill(keyOpad, KEY_LENGTH, HMAC_SHA256_BLOCK_SIZE, OUTER_PADDING_BYTE);
+
+        byte[] innerSha256Result = Hashing.sha256().hashBytes(concat(keyIpad, data)).asBytes();
+        return Hashing.sha256().hashBytes(concat(keyOpad, innerSha256Result)).asBytes();
+    }
+
+    // Adds this test example on spec. Also we can easily change the parameters(e.g. secret, data)
+    // to clarify test results with partners.
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTestExampleToHmacSha256_getCorrectResult() {
+        byte[] secret = base16().decode("0123456789ABCDEF0123456789ABCDEF");
+        byte[] data =
+                base16().decode(
+                        "0001020304050607EE4A2483738052E44E9B2A145E5DDFAA44B9E5536AF438E1E5C6");
+
+        byte[] hmacResult = doHmacSha256(secret, data);
+
+        assertThat(hmacResult)
+                .isEqualTo(base16().decode(
+                        "55EC5E6055AF6E92618B7D8710D4413709AB5DA27CA26A66F52E5AD4E8209052"));
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/MessageStreamHmacEncoderTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/MessageStreamHmacEncoderTest.java
new file mode 100644
index 0000000..d4c3342
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/MessageStreamHmacEncoderTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.MessageStreamHmacEncoder.EXTRACT_HMAC_SIZE;
+import static com.android.server.nearby.common.bluetooth.fastpair.MessageStreamHmacEncoder.SECTION_NONCE_LENGTH;
+
+import static com.google.common.primitives.Bytes.concat;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+/**
+ * Unit tests for {@link MessageStreamHmacEncoder}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class MessageStreamHmacEncoderTest {
+
+    private static final int ACCOUNT_KEY_LENGTH = 16;
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void encodeMessagePacket() throws GeneralSecurityException {
+        int messageLength = 2;
+        SecureRandom secureRandom = new SecureRandom();
+        byte[] accountKey = new byte[ACCOUNT_KEY_LENGTH];
+        secureRandom.nextBytes(accountKey);
+        byte[] data = new byte[messageLength];
+        secureRandom.nextBytes(data);
+        byte[] sectionNonce = new byte[SECTION_NONCE_LENGTH];
+        secureRandom.nextBytes(sectionNonce);
+
+        byte[] result = MessageStreamHmacEncoder
+                .encodeMessagePacket(accountKey, sectionNonce, data);
+
+        assertThat(result).hasLength(messageLength + SECTION_NONCE_LENGTH + EXTRACT_HMAC_SIZE);
+        // First bytes are raw message bytes.
+        assertThat(Arrays.copyOf(result, messageLength)).isEqualTo(data);
+        // Following by message nonce.
+        byte[] messageNonce =
+                Arrays.copyOfRange(result, messageLength, messageLength + SECTION_NONCE_LENGTH);
+        byte[] extractedHmac =
+                Arrays.copyOf(
+                        HmacSha256.buildWith64BytesKey(accountKey,
+                                concat(sectionNonce, messageNonce, data)),
+                        EXTRACT_HMAC_SIZE);
+        // Finally hash mac.
+        assertThat(Arrays.copyOfRange(result, messageLength + SECTION_NONCE_LENGTH, result.length))
+                .isEqualTo(extractedHmac);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void verifyHmac() throws GeneralSecurityException {
+        int messageLength = 2;
+        SecureRandom secureRandom = new SecureRandom();
+        byte[] accountKey = new byte[ACCOUNT_KEY_LENGTH];
+        secureRandom.nextBytes(accountKey);
+        byte[] data = new byte[messageLength];
+        secureRandom.nextBytes(data);
+        byte[] sectionNonce = new byte[SECTION_NONCE_LENGTH];
+        secureRandom.nextBytes(sectionNonce);
+        byte[] result = MessageStreamHmacEncoder
+                .encodeMessagePacket(accountKey, sectionNonce, data);
+
+        assertThat(MessageStreamHmacEncoder.verifyHmac(accountKey, sectionNonce, result)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void verifyHmac_failedByAccountKey() throws GeneralSecurityException {
+        int messageLength = 2;
+        SecureRandom secureRandom = new SecureRandom();
+        byte[] accountKey = new byte[ACCOUNT_KEY_LENGTH];
+        secureRandom.nextBytes(accountKey);
+        byte[] data = new byte[messageLength];
+        secureRandom.nextBytes(data);
+        byte[] sectionNonce = new byte[SECTION_NONCE_LENGTH];
+        secureRandom.nextBytes(sectionNonce);
+        byte[] result = MessageStreamHmacEncoder
+                .encodeMessagePacket(accountKey, sectionNonce, data);
+        secureRandom.nextBytes(accountKey);
+
+        assertThat(MessageStreamHmacEncoder.verifyHmac(accountKey, sectionNonce, result)).isFalse();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void verifyHmac_failedBySectionNonce() throws GeneralSecurityException {
+        int messageLength = 2;
+        SecureRandom secureRandom = new SecureRandom();
+        byte[] accountKey = new byte[ACCOUNT_KEY_LENGTH];
+        secureRandom.nextBytes(accountKey);
+        byte[] data = new byte[messageLength];
+        secureRandom.nextBytes(data);
+        byte[] sectionNonce = new byte[SECTION_NONCE_LENGTH];
+        secureRandom.nextBytes(sectionNonce);
+        byte[] result = MessageStreamHmacEncoder
+                .encodeMessagePacket(accountKey, sectionNonce, data);
+        secureRandom.nextBytes(sectionNonce);
+
+        assertThat(MessageStreamHmacEncoder.verifyHmac(accountKey, sectionNonce, result)).isFalse();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/NamingEncoderTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/NamingEncoderTest.java
new file mode 100644
index 0000000..d66d209
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/NamingEncoderTest.java
@@ -0,0 +1,149 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.KEY_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesCtrMultipleBlockEncryption.NONCE_SIZE;
+import static com.android.server.nearby.common.bluetooth.fastpair.NamingEncoder.EXTRACT_HMAC_SIZE;
+import static com.android.server.nearby.common.bluetooth.fastpair.NamingEncoder.MAX_LENGTH_OF_NAME;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.security.GeneralSecurityException;
+
+/**
+ * Unit tests for {@link NamingEncoder}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class NamingEncoderTest {
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void decodeEncodedNamingPacket_mustGetSameName() throws GeneralSecurityException {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        String name = "Someone's Google Headphone";
+
+        byte[] encodedNamingPacket = NamingEncoder.encodeNamingPacket(secret, name);
+
+        assertThat(NamingEncoder.decodeNamingPacket(secret, encodedNamingPacket)).isEqualTo(name);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectKeySizeToEncode_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH - 1];
+        String data = "Someone's Google Headphone";
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> NamingEncoder.encodeNamingPacket(secret, data));
+
+        assertThat(exception).hasMessageThat()
+                .contains("Incorrect secret for encoding name packet");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectKeySizeToDecode_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH - 1];
+        byte[] data = new byte[50];
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> NamingEncoder.decodeNamingPacket(secret, data));
+
+        assertThat(exception).hasMessageThat()
+                .contains("Incorrect secret for decoding name packet");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTooSmallPacketSize_mustThrowException() {
+        byte[] secret = new byte[KEY_LENGTH];
+        byte[] data = new byte[EXTRACT_HMAC_SIZE - 1];
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> NamingEncoder.decodeNamingPacket(secret, data));
+
+        assertThat(exception).hasMessageThat().contains("Naming packet size is incorrect");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputTooLargePacketSize_mustThrowException() throws GeneralSecurityException {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        byte[] namingPacket = new byte[MAX_LENGTH_OF_NAME + EXTRACT_HMAC_SIZE + NONCE_SIZE + 1];
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> NamingEncoder.decodeNamingPacket(secret, namingPacket));
+
+        assertThat(exception).hasMessageThat().contains("Naming packet size is incorrect");
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void inputIncorrectHmacToDecode_mustThrowException() throws GeneralSecurityException {
+        byte[] secret = AesCtrMultipleBlockEncryption.generateKey();
+        String name = "Someone's Google Headphone";
+
+        byte[] encodedNamingPacket = NamingEncoder.encodeNamingPacket(secret, name);
+        encodedNamingPacket[0] = (byte) ~encodedNamingPacket[0];
+
+        GeneralSecurityException exception =
+                assertThrows(
+                        GeneralSecurityException.class,
+                        () -> NamingEncoder.decodeNamingPacket(secret, encodedNamingPacket));
+
+        assertThat(exception)
+                .hasMessageThat()
+                .contains("Verify HMAC failed, could be incorrect key or naming packet.");
+    }
+
+    // Adds this test example on spec. Also we can easily change the parameters(e.g. secret, naming
+    // packet) to clarify test results with partners.
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void decodeTestNamingPacket_mustGetSameName() throws GeneralSecurityException {
+        byte[] secret = base16().decode("0123456789ABCDEF0123456789ABCDEF");
+        byte[] namingPacket = base16().decode(
+                "55EC5E6055AF6E920001020304050607EE4A2483738052E44E9B2A145E5DDFAA44B9E5536AF438"
+                        + "E1E5C6");
+
+        assertThat(NamingEncoder.decodeNamingPacket(secret, namingPacket))
+                .isEqualTo("Someone's Google Headphone");
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/PreferencesTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/PreferencesTest.java
new file mode 100644
index 0000000..b40a5a5
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/PreferencesTest.java
@@ -0,0 +1,1277 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Unit tests for {@link Preferences}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class PreferencesTest {
+
+    private static final int FIRST_INT = 1505;
+    private static final int SECOND_INT = 1506;
+    private static final boolean FIRST_BOOL = true;
+    private static final boolean SECOND_BOOL = false;
+    private static final short FIRST_SHORT = 32;
+    private static final short SECOND_SHORT = 73;
+    private static final long FIRST_LONG = 9838L;
+    private static final long SECOND_LONG = 93935L;
+    private static final String FIRST_STRING = "FIRST_STRING";
+    private static final String SECOND_STRING = "SECOND_STRING";
+    private static final byte[] FIRST_BYTES = new byte[] {7, 9};
+    private static final byte[] SECOND_BYTES = new byte[] {2};
+    private static final ImmutableSet<Integer> FIRST_INT_SETS = ImmutableSet.of(6, 8);
+    private static final ImmutableSet<Integer> SECOND_INT_SETS = ImmutableSet.of(6, 8);
+    private static final Preferences.ExtraLoggingInformation FIRST_EXTRA_LOGGING_INFO =
+            Preferences.ExtraLoggingInformation.builder().setModelId("000006").build();
+    private static final Preferences.ExtraLoggingInformation SECOND_EXTRA_LOGGING_INFO =
+            Preferences.ExtraLoggingInformation.builder().setModelId("000007").build();
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattOperationTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setGattOperationTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getGattOperationTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getGattOperationTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setGattOperationTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getGattOperationTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattConnectionTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setGattConnectionTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getGattConnectionTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getGattConnectionTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setGattConnectionTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getGattConnectionTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothToggleTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setBluetoothToggleTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getBluetoothToggleTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getBluetoothToggleTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setBluetoothToggleTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getBluetoothToggleTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothToggleSleepSeconds() {
+        Preferences prefs =
+                Preferences.builder().setBluetoothToggleSleepSeconds(FIRST_INT).build();
+        assertThat(prefs.getBluetoothToggleSleepSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getBluetoothToggleSleepSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setBluetoothToggleSleepSeconds(SECOND_INT).build();
+        assertThat(prefs2.getBluetoothToggleSleepSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testClassicDiscoveryTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setClassicDiscoveryTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getClassicDiscoveryTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getClassicDiscoveryTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setClassicDiscoveryTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getClassicDiscoveryTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNumDiscoverAttempts() {
+        Preferences prefs =
+                Preferences.builder().setNumDiscoverAttempts(FIRST_INT).build();
+        assertThat(prefs.getNumDiscoverAttempts()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getNumDiscoverAttempts())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setNumDiscoverAttempts(SECOND_INT).build();
+        assertThat(prefs2.getNumDiscoverAttempts()).isEqualTo(SECOND_INT);
+    }
+
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testDiscoveryRetrySleepSeconds() {
+        Preferences prefs =
+                Preferences.builder().setDiscoveryRetrySleepSeconds(FIRST_INT).build();
+        assertThat(prefs.getDiscoveryRetrySleepSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getDiscoveryRetrySleepSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setDiscoveryRetrySleepSeconds(SECOND_INT).build();
+        assertThat(prefs2.getDiscoveryRetrySleepSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSdpTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setSdpTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getSdpTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getSdpTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setSdpTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getSdpTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNumSdpAttempts() {
+        Preferences prefs =
+                Preferences.builder().setNumSdpAttempts(FIRST_INT).build();
+        assertThat(prefs.getNumSdpAttempts()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getNumSdpAttempts())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setNumSdpAttempts(SECOND_INT).build();
+        assertThat(prefs2.getNumSdpAttempts()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNumCreateBondAttempts() {
+        Preferences prefs =
+                Preferences.builder().setNumCreateBondAttempts(FIRST_INT).build();
+        assertThat(prefs.getNumCreateBondAttempts()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getNumCreateBondAttempts())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setNumCreateBondAttempts(SECOND_INT).build();
+        assertThat(prefs2.getNumCreateBondAttempts()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNumConnectAttempts() {
+        Preferences prefs =
+                Preferences.builder().setNumConnectAttempts(FIRST_INT).build();
+        assertThat(prefs.getNumConnectAttempts()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getNumConnectAttempts())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setNumConnectAttempts(SECOND_INT).build();
+        assertThat(prefs2.getNumConnectAttempts()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNumWriteAccountKeyAttempts() {
+        Preferences prefs =
+                Preferences.builder().setNumWriteAccountKeyAttempts(FIRST_INT).build();
+        assertThat(prefs.getNumWriteAccountKeyAttempts()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getNumWriteAccountKeyAttempts())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setNumWriteAccountKeyAttempts(SECOND_INT).build();
+        assertThat(prefs2.getNumWriteAccountKeyAttempts()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothStatePollingMillis() {
+        Preferences prefs =
+                Preferences.builder().setBluetoothStatePollingMillis(FIRST_INT).build();
+        assertThat(prefs.getBluetoothStatePollingMillis()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getBluetoothStatePollingMillis())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setBluetoothStatePollingMillis(SECOND_INT).build();
+        assertThat(prefs2.getBluetoothStatePollingMillis()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNumAttempts() {
+        Preferences prefs =
+                Preferences.builder().setNumAttempts(FIRST_INT).build();
+        assertThat(prefs.getNumAttempts()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getNumAttempts())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setNumAttempts(SECOND_INT).build();
+        assertThat(prefs2.getNumAttempts()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testRemoveBondTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setRemoveBondTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getRemoveBondTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getRemoveBondTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setRemoveBondTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getRemoveBondTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testRemoveBondSleepMillis() {
+        Preferences prefs =
+                Preferences.builder().setRemoveBondSleepMillis(FIRST_INT).build();
+        assertThat(prefs.getRemoveBondSleepMillis()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getRemoveBondSleepMillis())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setRemoveBondSleepMillis(SECOND_INT).build();
+        assertThat(prefs2.getRemoveBondSleepMillis()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCreateBondTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setCreateBondTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getCreateBondTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getCreateBondTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setCreateBondTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getCreateBondTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHidCreateBondTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setHidCreateBondTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getHidCreateBondTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getHidCreateBondTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setHidCreateBondTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getHidCreateBondTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testProxyTimeoutSeconds() {
+        Preferences prefs =
+                Preferences.builder().setProxyTimeoutSeconds(FIRST_INT).build();
+        assertThat(prefs.getProxyTimeoutSeconds()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getProxyTimeoutSeconds())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setProxyTimeoutSeconds(SECOND_INT).build();
+        assertThat(prefs2.getProxyTimeoutSeconds()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWriteAccountKeySleepMillis() {
+        Preferences prefs =
+                Preferences.builder().setWriteAccountKeySleepMillis(FIRST_INT).build();
+        assertThat(prefs.getWriteAccountKeySleepMillis()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getWriteAccountKeySleepMillis())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setWriteAccountKeySleepMillis(SECOND_INT).build();
+        assertThat(prefs2.getWriteAccountKeySleepMillis()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testPairFailureCounts() {
+        Preferences prefs =
+                Preferences.builder().setPairFailureCounts(FIRST_INT).build();
+        assertThat(prefs.getPairFailureCounts()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getPairFailureCounts())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setPairFailureCounts(SECOND_INT).build();
+        assertThat(prefs2.getPairFailureCounts()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCreateBondTransportType() {
+        Preferences prefs =
+                Preferences.builder().setCreateBondTransportType(FIRST_INT).build();
+        assertThat(prefs.getCreateBondTransportType()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getCreateBondTransportType())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setCreateBondTransportType(SECOND_INT).build();
+        assertThat(prefs2.getCreateBondTransportType()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattConnectRetryTimeoutMillis() {
+        Preferences prefs =
+                Preferences.builder().setGattConnectRetryTimeoutMillis(FIRST_INT).build();
+        assertThat(prefs.getGattConnectRetryTimeoutMillis()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getGattConnectRetryTimeoutMillis())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setGattConnectRetryTimeoutMillis(SECOND_INT).build();
+        assertThat(prefs2.getGattConnectRetryTimeoutMillis()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNumSdpAttemptsAfterBonded() {
+        Preferences prefs =
+                Preferences.builder().setNumSdpAttemptsAfterBonded(FIRST_INT).build();
+        assertThat(prefs.getNumSdpAttemptsAfterBonded()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getNumSdpAttemptsAfterBonded())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setNumSdpAttemptsAfterBonded(SECOND_INT).build();
+        assertThat(prefs2.getNumSdpAttemptsAfterBonded()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSameModelIdPairedDeviceCount() {
+        Preferences prefs =
+                Preferences.builder().setSameModelIdPairedDeviceCount(FIRST_INT).build();
+        assertThat(prefs.getSameModelIdPairedDeviceCount()).isEqualTo(FIRST_INT);
+        assertThat(prefs.toBuilder().build().getSameModelIdPairedDeviceCount())
+                .isEqualTo(FIRST_INT);
+
+        Preferences prefs2 =
+                Preferences.builder().setSameModelIdPairedDeviceCount(SECOND_INT).build();
+        assertThat(prefs2.getSameModelIdPairedDeviceCount()).isEqualTo(SECOND_INT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIgnoreDiscoveryError() {
+        Preferences prefs =
+                Preferences.builder().setIgnoreDiscoveryError(FIRST_BOOL).build();
+        assertThat(prefs.getIgnoreDiscoveryError()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getIgnoreDiscoveryError())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setIgnoreDiscoveryError(SECOND_BOOL).build();
+        assertThat(prefs2.getIgnoreDiscoveryError()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testToggleBluetoothOnFailure() {
+        Preferences prefs =
+                Preferences.builder().setToggleBluetoothOnFailure(FIRST_BOOL).build();
+        assertThat(prefs.getToggleBluetoothOnFailure()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getToggleBluetoothOnFailure())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setToggleBluetoothOnFailure(SECOND_BOOL).build();
+        assertThat(prefs2.getToggleBluetoothOnFailure()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothStateUsesPolling() {
+        Preferences prefs =
+                Preferences.builder().setBluetoothStateUsesPolling(FIRST_BOOL).build();
+        assertThat(prefs.getBluetoothStateUsesPolling()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getBluetoothStateUsesPolling())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setBluetoothStateUsesPolling(SECOND_BOOL).build();
+        assertThat(prefs2.getBluetoothStateUsesPolling()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnableBrEdrHandover() {
+        Preferences prefs =
+                Preferences.builder().setEnableBrEdrHandover(FIRST_BOOL).build();
+        assertThat(prefs.getEnableBrEdrHandover()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnableBrEdrHandover())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnableBrEdrHandover(SECOND_BOOL).build();
+        assertThat(prefs2.getEnableBrEdrHandover()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWaitForUuidsAfterBonding() {
+        Preferences prefs =
+                Preferences.builder().setWaitForUuidsAfterBonding(FIRST_BOOL).build();
+        assertThat(prefs.getWaitForUuidsAfterBonding()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getWaitForUuidsAfterBonding())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setWaitForUuidsAfterBonding(SECOND_BOOL).build();
+        assertThat(prefs2.getWaitForUuidsAfterBonding()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testReceiveUuidsAndBondedEventBeforeClose() {
+        Preferences prefs =
+                Preferences.builder().setReceiveUuidsAndBondedEventBeforeClose(FIRST_BOOL).build();
+        assertThat(prefs.getReceiveUuidsAndBondedEventBeforeClose()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getReceiveUuidsAndBondedEventBeforeClose())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setReceiveUuidsAndBondedEventBeforeClose(SECOND_BOOL).build();
+        assertThat(prefs2.getReceiveUuidsAndBondedEventBeforeClose()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testRejectPhonebookAccess() {
+        Preferences prefs =
+                Preferences.builder().setRejectPhonebookAccess(FIRST_BOOL).build();
+        assertThat(prefs.getRejectPhonebookAccess()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getRejectPhonebookAccess())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setRejectPhonebookAccess(SECOND_BOOL).build();
+        assertThat(prefs2.getRejectPhonebookAccess()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testRejectMessageAccess() {
+        Preferences prefs =
+                Preferences.builder().setRejectMessageAccess(FIRST_BOOL).build();
+        assertThat(prefs.getRejectMessageAccess()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getRejectMessageAccess())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setRejectMessageAccess(SECOND_BOOL).build();
+        assertThat(prefs2.getRejectMessageAccess()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testRejectSimAccess() {
+        Preferences prefs =
+                Preferences.builder().setRejectSimAccess(FIRST_BOOL).build();
+        assertThat(prefs.getRejectSimAccess()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getRejectSimAccess())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setRejectSimAccess(SECOND_BOOL).build();
+        assertThat(prefs2.getRejectSimAccess()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSkipDisconnectingGattBeforeWritingAccountKey() {
+        Preferences prefs =
+                Preferences.builder().setSkipDisconnectingGattBeforeWritingAccountKey(FIRST_BOOL)
+                        .build();
+        assertThat(prefs.getSkipDisconnectingGattBeforeWritingAccountKey()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getSkipDisconnectingGattBeforeWritingAccountKey())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setSkipDisconnectingGattBeforeWritingAccountKey(SECOND_BOOL)
+                        .build();
+        assertThat(prefs2.getSkipDisconnectingGattBeforeWritingAccountKey()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testMoreEventLogForQuality() {
+        Preferences prefs =
+                Preferences.builder().setMoreEventLogForQuality(FIRST_BOOL).build();
+        assertThat(prefs.getMoreEventLogForQuality()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getMoreEventLogForQuality())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setMoreEventLogForQuality(SECOND_BOOL).build();
+        assertThat(prefs2.getMoreEventLogForQuality()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testRetryGattConnectionAndSecretHandshake() {
+        Preferences prefs =
+                Preferences.builder().setRetryGattConnectionAndSecretHandshake(FIRST_BOOL).build();
+        assertThat(prefs.getRetryGattConnectionAndSecretHandshake()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getRetryGattConnectionAndSecretHandshake())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setRetryGattConnectionAndSecretHandshake(SECOND_BOOL).build();
+        assertThat(prefs2.getRetryGattConnectionAndSecretHandshake()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testRetrySecretHandshakeTimeout() {
+        Preferences prefs =
+                Preferences.builder().setRetrySecretHandshakeTimeout(FIRST_BOOL).build();
+        assertThat(prefs.getRetrySecretHandshakeTimeout()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getRetrySecretHandshakeTimeout())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setRetrySecretHandshakeTimeout(SECOND_BOOL).build();
+        assertThat(prefs2.getRetrySecretHandshakeTimeout()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testLogUserManualRetry() {
+        Preferences prefs =
+                Preferences.builder().setLogUserManualRetry(FIRST_BOOL).build();
+        assertThat(prefs.getLogUserManualRetry()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getLogUserManualRetry())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setLogUserManualRetry(SECOND_BOOL).build();
+        assertThat(prefs2.getLogUserManualRetry()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIsDeviceFinishCheckAddressFromCache() {
+        Preferences prefs =
+                Preferences.builder().setIsDeviceFinishCheckAddressFromCache(FIRST_BOOL).build();
+        assertThat(prefs.getIsDeviceFinishCheckAddressFromCache()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getIsDeviceFinishCheckAddressFromCache())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setIsDeviceFinishCheckAddressFromCache(SECOND_BOOL).build();
+        assertThat(prefs2.getIsDeviceFinishCheckAddressFromCache()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testLogPairWithCachedModelId() {
+        Preferences prefs =
+                Preferences.builder().setLogPairWithCachedModelId(FIRST_BOOL).build();
+        assertThat(prefs.getLogPairWithCachedModelId()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getLogPairWithCachedModelId())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setLogPairWithCachedModelId(SECOND_BOOL).build();
+        assertThat(prefs2.getLogPairWithCachedModelId()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testDirectConnectProfileIfModelIdInCache() {
+        Preferences prefs =
+                Preferences.builder().setDirectConnectProfileIfModelIdInCache(FIRST_BOOL).build();
+        assertThat(prefs.getDirectConnectProfileIfModelIdInCache()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getDirectConnectProfileIfModelIdInCache())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setDirectConnectProfileIfModelIdInCache(SECOND_BOOL).build();
+        assertThat(prefs2.getDirectConnectProfileIfModelIdInCache()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAcceptPasskey() {
+        Preferences prefs =
+                Preferences.builder().setAcceptPasskey(FIRST_BOOL).build();
+        assertThat(prefs.getAcceptPasskey()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getAcceptPasskey())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setAcceptPasskey(SECOND_BOOL).build();
+        assertThat(prefs2.getAcceptPasskey()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testProviderInitiatesBondingIfSupported() {
+        Preferences prefs =
+                Preferences.builder().setProviderInitiatesBondingIfSupported(FIRST_BOOL).build();
+        assertThat(prefs.getProviderInitiatesBondingIfSupported()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getProviderInitiatesBondingIfSupported())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setProviderInitiatesBondingIfSupported(SECOND_BOOL).build();
+        assertThat(prefs2.getProviderInitiatesBondingIfSupported()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAttemptDirectConnectionWhenPreviouslyBonded() {
+        Preferences prefs =
+                Preferences.builder()
+                        .setAttemptDirectConnectionWhenPreviouslyBonded(FIRST_BOOL).build();
+        assertThat(prefs.getAttemptDirectConnectionWhenPreviouslyBonded()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getAttemptDirectConnectionWhenPreviouslyBonded())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder()
+                        .setAttemptDirectConnectionWhenPreviouslyBonded(SECOND_BOOL).build();
+        assertThat(prefs2.getAttemptDirectConnectionWhenPreviouslyBonded()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAutomaticallyReconnectGattWhenNeeded() {
+        Preferences prefs =
+                Preferences.builder().setAutomaticallyReconnectGattWhenNeeded(FIRST_BOOL).build();
+        assertThat(prefs.getAutomaticallyReconnectGattWhenNeeded()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getAutomaticallyReconnectGattWhenNeeded())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setAutomaticallyReconnectGattWhenNeeded(SECOND_BOOL).build();
+        assertThat(prefs2.getAutomaticallyReconnectGattWhenNeeded()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSkipConnectingProfiles() {
+        Preferences prefs =
+                Preferences.builder().setSkipConnectingProfiles(FIRST_BOOL).build();
+        assertThat(prefs.getSkipConnectingProfiles()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getSkipConnectingProfiles())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setSkipConnectingProfiles(SECOND_BOOL).build();
+        assertThat(prefs2.getSkipConnectingProfiles()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIgnoreUuidTimeoutAfterBonded() {
+        Preferences prefs =
+                Preferences.builder().setIgnoreUuidTimeoutAfterBonded(FIRST_BOOL).build();
+        assertThat(prefs.getIgnoreUuidTimeoutAfterBonded()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getIgnoreUuidTimeoutAfterBonded())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setIgnoreUuidTimeoutAfterBonded(SECOND_BOOL).build();
+        assertThat(prefs2.getIgnoreUuidTimeoutAfterBonded()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSpecifyCreateBondTransportType() {
+        Preferences prefs =
+                Preferences.builder().setSpecifyCreateBondTransportType(FIRST_BOOL).build();
+        assertThat(prefs.getSpecifyCreateBondTransportType()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getSpecifyCreateBondTransportType())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setSpecifyCreateBondTransportType(SECOND_BOOL).build();
+        assertThat(prefs2.getSpecifyCreateBondTransportType()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIncreaseIntentFilterPriority() {
+        Preferences prefs =
+                Preferences.builder().setIncreaseIntentFilterPriority(FIRST_BOOL).build();
+        assertThat(prefs.getIncreaseIntentFilterPriority()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getIncreaseIntentFilterPriority())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setIncreaseIntentFilterPriority(SECOND_BOOL).build();
+        assertThat(prefs2.getIncreaseIntentFilterPriority()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEvaluatePerformance() {
+        Preferences prefs =
+                Preferences.builder().setEvaluatePerformance(FIRST_BOOL).build();
+        assertThat(prefs.getEvaluatePerformance()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEvaluatePerformance())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEvaluatePerformance(SECOND_BOOL).build();
+        assertThat(prefs2.getEvaluatePerformance()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnableNamingCharacteristic() {
+        Preferences prefs =
+                Preferences.builder().setEnableNamingCharacteristic(FIRST_BOOL).build();
+        assertThat(prefs.getEnableNamingCharacteristic()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnableNamingCharacteristic())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnableNamingCharacteristic(SECOND_BOOL).build();
+        assertThat(prefs2.getEnableNamingCharacteristic()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnableFirmwareVersionCharacteristic() {
+        Preferences prefs =
+                Preferences.builder().setEnableFirmwareVersionCharacteristic(FIRST_BOOL).build();
+        assertThat(prefs.getEnableFirmwareVersionCharacteristic()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnableFirmwareVersionCharacteristic())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnableFirmwareVersionCharacteristic(SECOND_BOOL).build();
+        assertThat(prefs2.getEnableFirmwareVersionCharacteristic()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testKeepSameAccountKeyWrite() {
+        Preferences prefs =
+                Preferences.builder().setKeepSameAccountKeyWrite(FIRST_BOOL).build();
+        assertThat(prefs.getKeepSameAccountKeyWrite()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getKeepSameAccountKeyWrite())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setKeepSameAccountKeyWrite(SECOND_BOOL).build();
+        assertThat(prefs2.getKeepSameAccountKeyWrite()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testIsRetroactivePairing() {
+        Preferences prefs =
+                Preferences.builder().setIsRetroactivePairing(FIRST_BOOL).build();
+        assertThat(prefs.getIsRetroactivePairing()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getIsRetroactivePairing())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setIsRetroactivePairing(SECOND_BOOL).build();
+        assertThat(prefs2.getIsRetroactivePairing()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSupportHidDevice() {
+        Preferences prefs =
+                Preferences.builder().setSupportHidDevice(FIRST_BOOL).build();
+        assertThat(prefs.getSupportHidDevice()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getSupportHidDevice())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setSupportHidDevice(SECOND_BOOL).build();
+        assertThat(prefs2.getSupportHidDevice()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnablePairingWhileDirectlyConnecting() {
+        Preferences prefs =
+                Preferences.builder().setEnablePairingWhileDirectlyConnecting(FIRST_BOOL).build();
+        assertThat(prefs.getEnablePairingWhileDirectlyConnecting()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnablePairingWhileDirectlyConnecting())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnablePairingWhileDirectlyConnecting(SECOND_BOOL).build();
+        assertThat(prefs2.getEnablePairingWhileDirectlyConnecting()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAcceptConsentForFastPairOne() {
+        Preferences prefs =
+                Preferences.builder().setAcceptConsentForFastPairOne(FIRST_BOOL).build();
+        assertThat(prefs.getAcceptConsentForFastPairOne()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getAcceptConsentForFastPairOne())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setAcceptConsentForFastPairOne(SECOND_BOOL).build();
+        assertThat(prefs2.getAcceptConsentForFastPairOne()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnable128BitCustomGattCharacteristicsId() {
+        Preferences prefs =
+                Preferences.builder().setEnable128BitCustomGattCharacteristicsId(FIRST_BOOL)
+                        .build();
+        assertThat(prefs.getEnable128BitCustomGattCharacteristicsId()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnable128BitCustomGattCharacteristicsId())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnable128BitCustomGattCharacteristicsId(SECOND_BOOL)
+                        .build();
+        assertThat(prefs2.getEnable128BitCustomGattCharacteristicsId()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnableSendExceptionStepToValidator() {
+        Preferences prefs =
+                Preferences.builder().setEnableSendExceptionStepToValidator(FIRST_BOOL).build();
+        assertThat(prefs.getEnableSendExceptionStepToValidator()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnableSendExceptionStepToValidator())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnableSendExceptionStepToValidator(SECOND_BOOL).build();
+        assertThat(prefs2.getEnableSendExceptionStepToValidator()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnableAdditionalDataTypeWhenActionOverBle() {
+        Preferences prefs =
+                Preferences.builder().setEnableAdditionalDataTypeWhenActionOverBle(FIRST_BOOL)
+                        .build();
+        assertThat(prefs.getEnableAdditionalDataTypeWhenActionOverBle()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnableAdditionalDataTypeWhenActionOverBle())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnableAdditionalDataTypeWhenActionOverBle(SECOND_BOOL)
+                        .build();
+        assertThat(prefs2.getEnableAdditionalDataTypeWhenActionOverBle()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCheckBondStateWhenSkipConnectingProfiles() {
+        Preferences prefs =
+                Preferences.builder().setCheckBondStateWhenSkipConnectingProfiles(FIRST_BOOL)
+                        .build();
+        assertThat(prefs.getCheckBondStateWhenSkipConnectingProfiles()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getCheckBondStateWhenSkipConnectingProfiles())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setCheckBondStateWhenSkipConnectingProfiles(SECOND_BOOL)
+                        .build();
+        assertThat(prefs2.getCheckBondStateWhenSkipConnectingProfiles()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHandlePasskeyConfirmationByUi() {
+        Preferences prefs =
+                Preferences.builder().setHandlePasskeyConfirmationByUi(FIRST_BOOL).build();
+        assertThat(prefs.getHandlePasskeyConfirmationByUi()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getHandlePasskeyConfirmationByUi())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setHandlePasskeyConfirmationByUi(SECOND_BOOL).build();
+        assertThat(prefs2.getHandlePasskeyConfirmationByUi()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testEnablePairFlowShowUiWithoutProfileConnection() {
+        Preferences prefs =
+                Preferences.builder().setEnablePairFlowShowUiWithoutProfileConnection(FIRST_BOOL)
+                        .build();
+        assertThat(prefs.getEnablePairFlowShowUiWithoutProfileConnection()).isEqualTo(FIRST_BOOL);
+        assertThat(prefs.toBuilder().build().getEnablePairFlowShowUiWithoutProfileConnection())
+                .isEqualTo(FIRST_BOOL);
+
+        Preferences prefs2 =
+                Preferences.builder().setEnablePairFlowShowUiWithoutProfileConnection(SECOND_BOOL)
+                        .build();
+        assertThat(prefs2.getEnablePairFlowShowUiWithoutProfileConnection()).isEqualTo(SECOND_BOOL);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBrHandoverDataCharacteristicId() {
+        Preferences prefs =
+                Preferences.builder().setBrHandoverDataCharacteristicId(FIRST_SHORT).build();
+        assertThat(prefs.getBrHandoverDataCharacteristicId()).isEqualTo(FIRST_SHORT);
+        assertThat(prefs.toBuilder().build().getBrHandoverDataCharacteristicId())
+                .isEqualTo(FIRST_SHORT);
+
+        Preferences prefs2 =
+                Preferences.builder().setBrHandoverDataCharacteristicId(SECOND_SHORT).build();
+        assertThat(prefs2.getBrHandoverDataCharacteristicId()).isEqualTo(SECOND_SHORT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothSigDataCharacteristicId() {
+        Preferences prefs =
+                Preferences.builder().setBluetoothSigDataCharacteristicId(FIRST_SHORT).build();
+        assertThat(prefs.getBluetoothSigDataCharacteristicId()).isEqualTo(FIRST_SHORT);
+        assertThat(prefs.toBuilder().build().getBluetoothSigDataCharacteristicId())
+                .isEqualTo(FIRST_SHORT);
+
+        Preferences prefs2 =
+                Preferences.builder().setBluetoothSigDataCharacteristicId(SECOND_SHORT).build();
+        assertThat(prefs2.getBluetoothSigDataCharacteristicId()).isEqualTo(SECOND_SHORT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testFirmwareVersionCharacteristicId() {
+        Preferences prefs =
+                Preferences.builder().setFirmwareVersionCharacteristicId(FIRST_SHORT).build();
+        assertThat(prefs.getFirmwareVersionCharacteristicId()).isEqualTo(FIRST_SHORT);
+        assertThat(prefs.toBuilder().build().getFirmwareVersionCharacteristicId())
+                .isEqualTo(FIRST_SHORT);
+
+        Preferences prefs2 =
+                Preferences.builder().setFirmwareVersionCharacteristicId(SECOND_SHORT).build();
+        assertThat(prefs2.getFirmwareVersionCharacteristicId()).isEqualTo(SECOND_SHORT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBrTransportBlockDataDescriptorId() {
+        Preferences prefs =
+                Preferences.builder().setBrTransportBlockDataDescriptorId(FIRST_SHORT).build();
+        assertThat(prefs.getBrTransportBlockDataDescriptorId()).isEqualTo(FIRST_SHORT);
+        assertThat(prefs.toBuilder().build().getBrTransportBlockDataDescriptorId())
+                .isEqualTo(FIRST_SHORT);
+
+        Preferences prefs2 =
+                Preferences.builder().setBrTransportBlockDataDescriptorId(SECOND_SHORT).build();
+        assertThat(prefs2.getBrTransportBlockDataDescriptorId()).isEqualTo(SECOND_SHORT);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattConnectShortTimeoutMs() {
+        Preferences prefs =
+                Preferences.builder().setGattConnectShortTimeoutMs(FIRST_LONG).build();
+        assertThat(prefs.getGattConnectShortTimeoutMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getGattConnectShortTimeoutMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setGattConnectShortTimeoutMs(SECOND_LONG).build();
+        assertThat(prefs2.getGattConnectShortTimeoutMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattConnectLongTimeoutMs() {
+        Preferences prefs =
+                Preferences.builder().setGattConnectLongTimeoutMs(FIRST_LONG).build();
+        assertThat(prefs.getGattConnectLongTimeoutMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getGattConnectLongTimeoutMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setGattConnectLongTimeoutMs(SECOND_LONG).build();
+        assertThat(prefs2.getGattConnectLongTimeoutMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattConnectShortTimeoutRetryMaxSpentTimeMs() {
+        Preferences prefs =
+                Preferences.builder().setGattConnectShortTimeoutRetryMaxSpentTimeMs(FIRST_LONG)
+                        .build();
+        assertThat(prefs.getGattConnectShortTimeoutRetryMaxSpentTimeMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getGattConnectShortTimeoutRetryMaxSpentTimeMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setGattConnectShortTimeoutRetryMaxSpentTimeMs(SECOND_LONG)
+                        .build();
+        assertThat(prefs2.getGattConnectShortTimeoutRetryMaxSpentTimeMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testAddressRotateRetryMaxSpentTimeMs() {
+        Preferences prefs =
+                Preferences.builder().setAddressRotateRetryMaxSpentTimeMs(FIRST_LONG).build();
+        assertThat(prefs.getAddressRotateRetryMaxSpentTimeMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getAddressRotateRetryMaxSpentTimeMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setAddressRotateRetryMaxSpentTimeMs(SECOND_LONG).build();
+        assertThat(prefs2.getAddressRotateRetryMaxSpentTimeMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testPairingRetryDelayMs() {
+        Preferences prefs =
+                Preferences.builder().setPairingRetryDelayMs(FIRST_LONG).build();
+        assertThat(prefs.getPairingRetryDelayMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getPairingRetryDelayMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setPairingRetryDelayMs(SECOND_LONG).build();
+        assertThat(prefs2.getPairingRetryDelayMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSecretHandshakeShortTimeoutMs() {
+        Preferences prefs =
+                Preferences.builder().setSecretHandshakeShortTimeoutMs(FIRST_LONG).build();
+        assertThat(prefs.getSecretHandshakeShortTimeoutMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getSecretHandshakeShortTimeoutMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setSecretHandshakeShortTimeoutMs(SECOND_LONG).build();
+        assertThat(prefs2.getSecretHandshakeShortTimeoutMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSecretHandshakeLongTimeoutMs() {
+        Preferences prefs =
+                Preferences.builder().setSecretHandshakeLongTimeoutMs(FIRST_LONG).build();
+        assertThat(prefs.getSecretHandshakeLongTimeoutMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getSecretHandshakeLongTimeoutMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setSecretHandshakeLongTimeoutMs(SECOND_LONG).build();
+        assertThat(prefs2.getSecretHandshakeLongTimeoutMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSecretHandshakeShortTimeoutRetryMaxSpentTimeMs() {
+        Preferences prefs =
+                Preferences.builder().setSecretHandshakeShortTimeoutRetryMaxSpentTimeMs(FIRST_LONG)
+                        .build();
+        assertThat(prefs.getSecretHandshakeShortTimeoutRetryMaxSpentTimeMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getSecretHandshakeShortTimeoutRetryMaxSpentTimeMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setSecretHandshakeShortTimeoutRetryMaxSpentTimeMs(SECOND_LONG)
+                        .build();
+        assertThat(prefs2.getSecretHandshakeShortTimeoutRetryMaxSpentTimeMs())
+                .isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSecretHandshakeLongTimeoutRetryMaxSpentTimeMs() {
+        Preferences prefs =
+                Preferences.builder().setSecretHandshakeLongTimeoutRetryMaxSpentTimeMs(FIRST_LONG)
+                        .build();
+        assertThat(prefs.getSecretHandshakeLongTimeoutRetryMaxSpentTimeMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getSecretHandshakeLongTimeoutRetryMaxSpentTimeMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setSecretHandshakeLongTimeoutRetryMaxSpentTimeMs(SECOND_LONG)
+                        .build();
+        assertThat(prefs2.getSecretHandshakeLongTimeoutRetryMaxSpentTimeMs())
+                .isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSecretHandshakeRetryAttempts() {
+        Preferences prefs =
+                Preferences.builder().setSecretHandshakeRetryAttempts(FIRST_LONG).build();
+        assertThat(prefs.getSecretHandshakeRetryAttempts()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getSecretHandshakeRetryAttempts())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setSecretHandshakeRetryAttempts(SECOND_LONG).build();
+        assertThat(prefs2.getSecretHandshakeRetryAttempts()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSecretHandshakeRetryGattConnectionMaxSpentTimeMs() {
+        Preferences prefs =
+                Preferences.builder()
+                        .setSecretHandshakeRetryGattConnectionMaxSpentTimeMs(FIRST_LONG).build();
+        assertThat(prefs.getSecretHandshakeRetryGattConnectionMaxSpentTimeMs())
+                .isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getSecretHandshakeRetryGattConnectionMaxSpentTimeMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setSecretHandshakeRetryGattConnectionMaxSpentTimeMs(
+                        SECOND_LONG).build();
+        assertThat(prefs2.getSecretHandshakeRetryGattConnectionMaxSpentTimeMs())
+                .isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSignalLostRetryMaxSpentTimeMs() {
+        Preferences prefs =
+                Preferences.builder().setSignalLostRetryMaxSpentTimeMs(FIRST_LONG).build();
+        assertThat(prefs.getSignalLostRetryMaxSpentTimeMs()).isEqualTo(FIRST_LONG);
+        assertThat(prefs.toBuilder().build().getSignalLostRetryMaxSpentTimeMs())
+                .isEqualTo(FIRST_LONG);
+
+        Preferences prefs2 =
+                Preferences.builder().setSignalLostRetryMaxSpentTimeMs(SECOND_LONG).build();
+        assertThat(prefs2.getSignalLostRetryMaxSpentTimeMs()).isEqualTo(SECOND_LONG);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testCachedDeviceAddress() {
+        Preferences prefs =
+                Preferences.builder().setCachedDeviceAddress(FIRST_STRING).build();
+        assertThat(prefs.getCachedDeviceAddress()).isEqualTo(FIRST_STRING);
+        assertThat(prefs.toBuilder().build().getCachedDeviceAddress())
+                .isEqualTo(FIRST_STRING);
+
+        Preferences prefs2 =
+                Preferences.builder().setCachedDeviceAddress(SECOND_STRING).build();
+        assertThat(prefs2.getCachedDeviceAddress()).isEqualTo(SECOND_STRING);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testPossibleCachedDeviceAddress() {
+        Preferences prefs =
+                Preferences.builder().setPossibleCachedDeviceAddress(FIRST_STRING).build();
+        assertThat(prefs.getPossibleCachedDeviceAddress()).isEqualTo(FIRST_STRING);
+        assertThat(prefs.toBuilder().build().getPossibleCachedDeviceAddress())
+                .isEqualTo(FIRST_STRING);
+
+        Preferences prefs2 =
+                Preferences.builder().setPossibleCachedDeviceAddress(SECOND_STRING).build();
+        assertThat(prefs2.getPossibleCachedDeviceAddress()).isEqualTo(SECOND_STRING);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSupportedProfileUuids() {
+        Preferences prefs =
+                Preferences.builder().setSupportedProfileUuids(FIRST_BYTES).build();
+        assertThat(prefs.getSupportedProfileUuids()).isEqualTo(FIRST_BYTES);
+        assertThat(prefs.toBuilder().build().getSupportedProfileUuids())
+                .isEqualTo(FIRST_BYTES);
+
+        Preferences prefs2 =
+                Preferences.builder().setSupportedProfileUuids(SECOND_BYTES).build();
+        assertThat(prefs2.getSupportedProfileUuids()).isEqualTo(SECOND_BYTES);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGattConnectionAndSecretHandshakeNoRetryGattError() {
+        Preferences prefs =
+                Preferences.builder().setGattConnectionAndSecretHandshakeNoRetryGattError(
+                        FIRST_INT_SETS).build();
+        assertThat(prefs.getGattConnectionAndSecretHandshakeNoRetryGattError())
+                .isEqualTo(FIRST_INT_SETS);
+        assertThat(prefs.toBuilder().build().getGattConnectionAndSecretHandshakeNoRetryGattError())
+                .isEqualTo(FIRST_INT_SETS);
+
+        Preferences prefs2 =
+                Preferences.builder().setGattConnectionAndSecretHandshakeNoRetryGattError(
+                        SECOND_INT_SETS).build();
+        assertThat(prefs2.getGattConnectionAndSecretHandshakeNoRetryGattError())
+                .isEqualTo(SECOND_INT_SETS);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testExtraLoggingInformation() {
+        Preferences prefs =
+                Preferences.builder().setExtraLoggingInformation(FIRST_EXTRA_LOGGING_INFO).build();
+        assertThat(prefs.getExtraLoggingInformation()).isEqualTo(FIRST_EXTRA_LOGGING_INFO);
+        assertThat(prefs.toBuilder().build().getExtraLoggingInformation())
+                .isEqualTo(FIRST_EXTRA_LOGGING_INFO);
+
+        Preferences prefs2 =
+                Preferences.builder().setExtraLoggingInformation(SECOND_EXTRA_LOGGING_INFO).build();
+        assertThat(prefs2.getExtraLoggingInformation()).isEqualTo(SECOND_EXTRA_LOGGING_INFO);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/TimingLoggerTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/TimingLoggerTest.java
new file mode 100644
index 0000000..4672905
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/fastpair/TimingLoggerTest.java
@@ -0,0 +1,218 @@
+/*
+ * 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.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.SystemClock;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.android.server.nearby.common.bluetooth.fastpair.TimingLogger.ScopedTiming;
+import com.android.server.nearby.common.bluetooth.fastpair.TimingLogger.Timing;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Unit tests for {@link TimingLogger}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class TimingLoggerTest {
+
+    private final Preferences mPrefs = Preferences.builder().setEvaluatePerformance(true).build();
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void logPairedTiming() {
+        String label = "start";
+        TimingLogger timingLogger = new TimingLogger("paired", mPrefs);
+        timingLogger.start(label);
+        SystemClock.sleep(1000);
+        timingLogger.end();
+
+        assertThat(timingLogger.getTimings()).hasSize(2);
+
+        // Calculate execution time and only store result at "start" timing.
+        // Expected output:
+        // <pre>
+        //  I/FastPair: paired [Exclusive time] / [Total time]
+        //  I/FastPair:   start 1000ms
+        //  I/FastPair: paired end, 1000ms
+        // </pre>
+        timingLogger.dump();
+
+        assertPairedTiming(label, timingLogger.getTimings().get(0),
+                timingLogger.getTimings().get(1));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void logScopedTiming() {
+        String label = "scopedTiming";
+        TimingLogger timingLogger = new TimingLogger("scoped", mPrefs);
+        try (ScopedTiming scopedTiming = new ScopedTiming(timingLogger, label)) {
+            SystemClock.sleep(1000);
+        }
+
+        assertThat(timingLogger.getTimings()).hasSize(2);
+
+        // Calculate execution time and only store result at "start" timings.
+        // Expected output:
+        // <pre>
+        //  I/FastPair: scoped [Exclusive time] / [Total time]
+        //  I/FastPair:   scopedTiming 1000ms
+        //  I/FastPair: scoped end, 1000ms
+        // </pre>
+        timingLogger.dump();
+
+        assertPairedTiming(label, timingLogger.getTimings().get(0),
+                timingLogger.getTimings().get(1));
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void logOrderedTiming() {
+        String label1 = "t1";
+        String label2 = "t2";
+        TimingLogger timingLogger = new TimingLogger("ordered", mPrefs);
+        try (ScopedTiming t1 = new ScopedTiming(timingLogger, label1)) {
+            SystemClock.sleep(1000);
+        }
+        try (ScopedTiming t2 = new ScopedTiming(timingLogger, label2)) {
+            SystemClock.sleep(1000);
+        }
+
+        assertThat(timingLogger.getTimings()).hasSize(4);
+
+        // Calculate execution time and only store result at "start" timings.
+        // Expected output:
+        // <pre>
+        //  I/FastPair: ordered [Exclusive time] / [Total time]
+        //  I/FastPair:   t1 1000ms
+        //  I/FastPair:   t2 1000ms
+        //  I/FastPair: ordered end, 2000ms
+        // </pre>
+        timingLogger.dump();
+
+        // We expect get timings in this order: t1 start, t1 end, t2 start, t2 end.
+        Timing start1 = timingLogger.getTimings().get(0);
+        Timing end1 = timingLogger.getTimings().get(1);
+        Timing start2 = timingLogger.getTimings().get(2);
+        Timing end2 = timingLogger.getTimings().get(3);
+
+        // Verify the paired timings.
+        assertPairedTiming(label1, start1, end1);
+        assertPairedTiming(label2, start2, end2);
+
+        // Verify the order and total time.
+        assertOrderedTiming(start1, start2);
+        assertThat(start1.getExclusiveTime() + start2.getExclusiveTime())
+                .isEqualTo(timingLogger.getTotalTime());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void logNestedTiming() {
+        String labelOuter = "outer";
+        String labelInner1 = "inner1";
+        String labelInner1Inner1 = "inner1inner1";
+        String labelInner2 = "inner2";
+        TimingLogger timingLogger = new TimingLogger("nested", mPrefs);
+        try (ScopedTiming outer = new ScopedTiming(timingLogger, labelOuter)) {
+            SystemClock.sleep(1000);
+            try (ScopedTiming inner1 = new ScopedTiming(timingLogger, labelInner1)) {
+                SystemClock.sleep(1000);
+                try (ScopedTiming inner1inner1 = new ScopedTiming(timingLogger,
+                        labelInner1Inner1)) {
+                    SystemClock.sleep(1000);
+                }
+            }
+            try (ScopedTiming inner2 = new ScopedTiming(timingLogger, labelInner2)) {
+                SystemClock.sleep(1000);
+            }
+        }
+
+        assertThat(timingLogger.getTimings()).hasSize(8);
+
+        // Calculate execution time and only store result at "start" timing.
+        // Expected output:
+        // <pre>
+        //  I/FastPair: nested [Exclusive time] / [Total time]
+        //  I/FastPair:   outer 1000ms / 4000ms
+        //  I/FastPair:     inner1 1000ms / 2000ms
+        //  I/FastPair:       inner1inner1 1000ms
+        //  I/FastPair:     inner2 1000ms
+        //  I/FastPair: nested end, 4000ms
+        // </pre>
+        timingLogger.dump();
+
+        // We expect get timings in this order: outer start, inner1 start, inner1inner1 start,
+        // inner1inner1 end, inner1 end, inner2 start, inner2 end, outer end.
+        Timing startOuter = timingLogger.getTimings().get(0);
+        Timing startInner1 = timingLogger.getTimings().get(1);
+        Timing startInner1Inner1 = timingLogger.getTimings().get(2);
+        Timing endInner1Inner1 = timingLogger.getTimings().get(3);
+        Timing endInner1 = timingLogger.getTimings().get(4);
+        Timing startInner2 = timingLogger.getTimings().get(5);
+        Timing endInner2 = timingLogger.getTimings().get(6);
+        Timing endOuter = timingLogger.getTimings().get(7);
+
+        // Verify the paired timings.
+        assertPairedTiming(labelOuter, startOuter, endOuter);
+        assertPairedTiming(labelInner1, startInner1, endInner1);
+        assertPairedTiming(labelInner1Inner1, startInner1Inner1, endInner1Inner1);
+        assertPairedTiming(labelInner2, startInner2, endInner2);
+
+        // Verify the order and total time.
+        assertOrderedTiming(startOuter, startInner1);
+        assertOrderedTiming(startInner1, startInner1Inner1);
+        assertOrderedTiming(startInner1Inner1, startInner2);
+        assertThat(
+                startOuter.getExclusiveTime() + startInner1.getTotalTime() + startInner2
+                        .getTotalTime())
+                .isEqualTo(timingLogger.getTotalTime());
+
+        // Verify the nested execution time.
+        assertThat(startInner1Inner1.getTotalTime()).isAtMost(startInner1.getTotalTime());
+        assertThat(startInner1.getTotalTime() + startInner2.getTotalTime())
+                .isAtMost(startOuter.getTotalTime());
+    }
+
+    private void assertPairedTiming(String label, Timing start, Timing end) {
+        assertThat(start.isStartTiming()).isTrue();
+        assertThat(start.getName()).isEqualTo(label);
+        assertThat(end.isEndTiming()).isTrue();
+        assertThat(end.getTimestamp()).isAtLeast(start.getTimestamp());
+
+        assertThat(start.getExclusiveTime() > 0).isTrue();
+        assertThat(start.getTotalTime()).isAtLeast(start.getExclusiveTime());
+        assertThat(end.getExclusiveTime() == 0).isTrue();
+        assertThat(end.getTotalTime() == 0).isTrue();
+    }
+
+    private void assertOrderedTiming(Timing t1, Timing t2) {
+        assertThat(t1.isStartTiming()).isTrue();
+        assertThat(t2.isStartTiming()).isTrue();
+        assertThat(t2.getTimestamp()).isAtLeast(t1.getTimestamp());
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnectionTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnectionTest.java
new file mode 100644
index 0000000..80bde63
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattConnectionTest.java
@@ -0,0 +1,848 @@
+/*
+ * 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.common.bluetooth.gatt;
+
+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.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.doThrow;
+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.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothStatusCodes;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+
+import androidx.test.filters.SdkSuppress;
+
+import com.android.server.nearby.common.bluetooth.BluetoothConsts;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.ReservedUuids;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection.ChangeObserver;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.OperationType;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattWrapper;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.SynchronousOperation;
+
+import junit.framework.TestCase;
+
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Captor;
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Unit tests for {@link BluetoothGattConnection}.
+ */
+public class BluetoothGattConnectionTest extends TestCase {
+
+    private static final UUID SERVICE_UUID = UUID.randomUUID();
+    private static final UUID CHARACTERISTIC_UUID = UUID.randomUUID();
+    private static final UUID DESCRIPTOR_UUID = UUID.randomUUID();
+    private static final byte[] DATA = "data".getBytes();
+    private static final int RSSI = -63;
+    private static final int CONNECTION_PRIORITY = 128;
+    private static final int MTU_REQUEST = 512;
+    private static final BluetoothGattHelper.ConnectionOptions CONNECTION_OPTIONS =
+            BluetoothGattHelper.ConnectionOptions.builder().build();
+
+    @Mock
+    private BluetoothGattWrapper mMockBluetoothGattWrapper;
+    @Mock
+    private BluetoothDevice mMockBluetoothDevice;
+    @Mock
+    private BluetoothOperationExecutor mMockBluetoothOperationExecutor;
+    @Mock
+    private BluetoothGattService mMockBluetoothGattService;
+    @Mock
+    private BluetoothGattService mMockBluetoothGattService2;
+    @Mock
+    private BluetoothGattCharacteristic mMockBluetoothGattCharacteristic;
+    @Mock
+    private BluetoothGattCharacteristic mMockBluetoothGattCharacteristic2;
+    @Mock
+    private BluetoothGattDescriptor mMockBluetoothGattDescriptor;
+    @Mock
+    private BluetoothGattConnection.CharacteristicChangeListener mMockCharChangeListener;
+    @Mock
+    private BluetoothGattConnection.ChangeObserver mMockChangeObserver;
+    @Mock
+    private BluetoothGattConnection.ConnectionCloseListener mMockConnectionCloseListener;
+
+    @Captor
+    private ArgumentCaptor<Operation<?>> mOperationCaptor;
+    @Captor
+    private ArgumentCaptor<SynchronousOperation<?>> mSynchronousOperationCaptor;
+    @Captor
+    private ArgumentCaptor<BluetoothGattCharacteristic> mCharacteristicCaptor;
+    @Captor
+    private ArgumentCaptor<BluetoothGattDescriptor> mDescriptorCaptor;
+
+    private BluetoothGattConnection mBluetoothGattConnection;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        initMocks(this);
+
+        mBluetoothGattConnection = new BluetoothGattConnection(
+                mMockBluetoothGattWrapper,
+                mMockBluetoothOperationExecutor,
+                CONNECTION_OPTIONS);
+        mBluetoothGattConnection.onConnected();
+
+        when(mMockBluetoothGattWrapper.getDevice()).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothGattWrapper.discoverServices()).thenReturn(true);
+        when(mMockBluetoothGattWrapper.refresh()).thenReturn(true);
+        when(mMockBluetoothGattWrapper.readCharacteristic(mMockBluetoothGattCharacteristic))
+                .thenReturn(true);
+        when(mMockBluetoothGattWrapper
+                .writeCharacteristic(ArgumentMatchers.<BluetoothGattCharacteristic>any(), any(),
+                        anyInt()))
+                .thenReturn(BluetoothStatusCodes.SUCCESS);
+        when(mMockBluetoothGattWrapper.readDescriptor(mMockBluetoothGattDescriptor))
+                .thenReturn(true);
+        when(mMockBluetoothGattWrapper.writeDescriptor(
+                ArgumentMatchers.<BluetoothGattDescriptor>any(), any()))
+                .thenReturn(BluetoothStatusCodes.SUCCESS);
+        when(mMockBluetoothGattWrapper.readRemoteRssi()).thenReturn(true);
+        when(mMockBluetoothGattWrapper.requestConnectionPriority(CONNECTION_PRIORITY))
+                .thenReturn(true);
+        when(mMockBluetoothGattWrapper.requestMtu(MTU_REQUEST)).thenReturn(true);
+        when(mMockBluetoothGattWrapper.getServices())
+                .thenReturn(Arrays.asList(mMockBluetoothGattService));
+        when(mMockBluetoothGattService.getUuid()).thenReturn(SERVICE_UUID);
+        when(mMockBluetoothGattService.getCharacteristics())
+                .thenReturn(Arrays.asList(mMockBluetoothGattCharacteristic));
+        when(mMockBluetoothGattCharacteristic.getUuid()).thenReturn(CHARACTERISTIC_UUID);
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(
+                        BluetoothGattCharacteristic.PROPERTY_NOTIFY
+                                | BluetoothGattCharacteristic.PROPERTY_WRITE);
+        BluetoothGattDescriptor clientConfigDescriptor =
+                new BluetoothGattDescriptor(
+                        ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION,
+                        BluetoothGattDescriptor.PERMISSION_WRITE);
+        when(mMockBluetoothGattCharacteristic.getDescriptor(
+                ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION))
+                .thenReturn(clientConfigDescriptor);
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(Arrays.asList(mMockBluetoothGattDescriptor, clientConfigDescriptor));
+        when(mMockBluetoothGattDescriptor.getUuid()).thenReturn(DESCRIPTOR_UUID);
+        when(mMockBluetoothDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getDevice() {
+        BluetoothDevice result = mBluetoothGattConnection.getDevice();
+
+        assertThat(result).isEqualTo(mMockBluetoothDevice);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getConnectionOptions() {
+        BluetoothGattHelper.ConnectionOptions result = mBluetoothGattConnection
+                .getConnectionOptions();
+
+        assertThat(result).isSameInstanceAs(CONNECTION_OPTIONS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_isConnected_false_beforeConnection() {
+        mBluetoothGattConnection = new BluetoothGattConnection(
+                mMockBluetoothGattWrapper,
+                mMockBluetoothOperationExecutor,
+                CONNECTION_OPTIONS);
+
+        boolean result = mBluetoothGattConnection.isConnected();
+
+        assertThat(result).isFalse();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_isConnected_true_afterConnection() {
+        boolean result = mBluetoothGattConnection.isConnected();
+
+        assertThat(result).isTrue();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_isConnected_false_afterDisconnection() {
+        mBluetoothGattConnection.onClosed();
+
+        boolean result = mBluetoothGattConnection.isConnected();
+
+        assertThat(result).isFalse();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getService_notDiscovered() throws Exception {
+        BluetoothGattService result = mBluetoothGattConnection.getService(SERVICE_UUID);
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor)
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+
+        assertThat(result).isEqualTo(mMockBluetoothGattService);
+        verify(mMockBluetoothGattWrapper).discoverServices();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getService_alreadyDiscovered() throws Exception {
+        mBluetoothGattConnection.getService(SERVICE_UUID);
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        reset(mMockBluetoothOperationExecutor);
+
+        BluetoothGattService result = mBluetoothGattConnection.getService(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattService);
+        // Verify that service discovery has been done only once
+        verifyNoMoreInteractions(mMockBluetoothOperationExecutor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getService_notFound() throws Exception {
+        when(mMockBluetoothGattWrapper.getServices()).thenReturn(
+                Arrays.<BluetoothGattService>asList());
+
+        try {
+            mBluetoothGattConnection.getService(SERVICE_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getService_moreThanOne() throws Exception {
+        when(mMockBluetoothGattWrapper.getServices())
+                .thenReturn(Arrays.asList(mMockBluetoothGattService, mMockBluetoothGattService));
+
+        try {
+            mBluetoothGattConnection.getService(SERVICE_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getCharacteristic() throws Exception {
+        BluetoothGattCharacteristic result =
+                mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattCharacteristic);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getCharacteristic_notFound() throws Exception {
+        when(mMockBluetoothGattService.getCharacteristics())
+                .thenReturn(Arrays.<BluetoothGattCharacteristic>asList());
+
+        try {
+            mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getCharacteristic_moreThanOne() throws Exception {
+        when(mMockBluetoothGattService.getCharacteristics())
+                .thenReturn(
+                        Arrays.asList(mMockBluetoothGattCharacteristic,
+                                mMockBluetoothGattCharacteristic));
+
+        try {
+            mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getCharacteristic_moreThanOneService() throws Exception {
+        // Add a new service with the same service UUID as our existing one, but add a different
+        // characteristic inside of it.
+        when(mMockBluetoothGattWrapper.getServices())
+                .thenReturn(Arrays.asList(mMockBluetoothGattService, mMockBluetoothGattService2));
+        when(mMockBluetoothGattService2.getUuid()).thenReturn(SERVICE_UUID);
+        when(mMockBluetoothGattService2.getCharacteristics())
+                .thenReturn(Arrays.asList(mMockBluetoothGattCharacteristic2));
+        when(mMockBluetoothGattCharacteristic2.getUuid())
+                .thenReturn(
+                        new UUID(
+                                CHARACTERISTIC_UUID.getMostSignificantBits(),
+                                CHARACTERISTIC_UUID.getLeastSignificantBits() + 1));
+        when(mMockBluetoothGattCharacteristic2.getProperties())
+                .thenReturn(
+                        BluetoothGattCharacteristic.PROPERTY_NOTIFY
+                                | BluetoothGattCharacteristic.PROPERTY_WRITE);
+
+        mBluetoothGattConnection.getCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getDescriptor() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(Arrays.asList(mMockBluetoothGattDescriptor));
+
+        BluetoothGattDescriptor result =
+                mBluetoothGattConnection
+                        .getDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattDescriptor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getDescriptor_notFound() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(Arrays.<BluetoothGattDescriptor>asList());
+
+        try {
+            mBluetoothGattConnection
+                    .getDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getDescriptor_moreThanOne() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getDescriptors())
+                .thenReturn(
+                        Arrays.asList(mMockBluetoothGattDescriptor, mMockBluetoothGattDescriptor));
+
+        try {
+            mBluetoothGattConnection
+                    .getDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_discoverServices() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor, OperationType.NOTIFICATION_CHANGE)))
+                .thenReturn(mMockChangeObserver);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor)
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).discoverServices();
+        verify(mMockBluetoothGattWrapper, never()).refresh();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_discoverServices_serviceChange() throws Exception {
+        when(mMockBluetoothGattWrapper.getService(ReservedUuids.Services.GENERIC_ATTRIBUTE))
+                .thenReturn(mMockBluetoothGattService);
+        when(mMockBluetoothGattService
+                .getCharacteristic(ReservedUuids.Characteristics.SERVICE_CHANGE))
+                .thenReturn(mMockBluetoothGattCharacteristic);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGattWrapper).refresh();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_discoverServices_SelfDefinedServiceDynamic() throws Exception {
+        when(mMockBluetoothGattWrapper.getService(BluetoothConsts.SERVICE_DYNAMIC_SERVICE))
+                .thenReturn(mMockBluetoothGattService);
+        when(mMockBluetoothGattService
+                .getCharacteristic(BluetoothConsts.SERVICE_DYNAMIC_CHARACTERISTIC))
+                .thenReturn(mMockBluetoothGattCharacteristic);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGattWrapper).refresh();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_discoverServices_refreshWithGattErrorOnMncAbove() throws Exception {
+        if (VERSION.SDK_INT <= VERSION_CODES.LOLLIPOP_MR1) {
+            return;
+        }
+        mBluetoothGattConnection.discoverServices();
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+
+        doThrow(new BluetoothGattException("fail", BluetoothGattConnection.GATT_ERROR))
+                .doReturn(null)
+                .when(mMockBluetoothOperationExecutor)
+                .execute(isA(Operation.class),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGattWrapper).refresh();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_discoverServices_refreshWithGattInternalErrorOnMncAbove() throws Exception {
+        if (VERSION.SDK_INT <= VERSION_CODES.LOLLIPOP_MR1) {
+            return;
+        }
+        mBluetoothGattConnection.discoverServices();
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+
+        doThrow(new BluetoothGattException("fail", BluetoothGattConnection.GATT_INTERNAL_ERROR))
+                .doReturn(null)
+                .when(mMockBluetoothOperationExecutor)
+                .execute(isA(Operation.class),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        mSynchronousOperationCaptor.getValue().call();
+        verify(mMockBluetoothOperationExecutor, times(2))
+                .execute(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.SLOW_OPERATION_TIMEOUT_MILLIS));
+        verify(mMockBluetoothGattWrapper).refresh();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_discoverServices_dynamicServices_notBonded() throws Exception {
+        when(mMockBluetoothGattWrapper.getService(ReservedUuids.Services.GENERIC_ATTRIBUTE))
+                .thenReturn(mMockBluetoothGattService);
+        when(mMockBluetoothGattService
+                .getCharacteristic(ReservedUuids.Characteristics.SERVICE_CHANGE))
+                .thenReturn(mMockBluetoothGattCharacteristic);
+        when(mMockBluetoothDevice.getBondState()).thenReturn(BluetoothDevice.BOND_NONE);
+
+        mBluetoothGattConnection.discoverServices();
+
+        verify(mMockBluetoothGattWrapper, never()).refresh();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_readCharacteristic() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_CHARACTERISTIC,
+                        mMockBluetoothGattWrapper,
+                        mMockBluetoothGattCharacteristic),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result = mBluetoothGattConnection
+                .readCharacteristic(mMockBluetoothGattCharacteristic);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).readCharacteristic(mMockBluetoothGattCharacteristic);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_readCharacteristic_by_uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_CHARACTERISTIC,
+                        mMockBluetoothGattWrapper,
+                        mMockBluetoothGattCharacteristic),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result = mBluetoothGattConnection
+                .readCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).readCharacteristic(mMockBluetoothGattCharacteristic);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_writeCharacteristic() throws Exception {
+        BluetoothGattCharacteristic characteristic =
+                new BluetoothGattCharacteristic(
+                        CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_WRITE, 0);
+        mBluetoothGattConnection.writeCharacteristic(characteristic, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeCharacteristic(mCharacteristicCaptor.capture(),
+                eq(DATA), eq(characteristic.getWriteType()));
+        BluetoothGattCharacteristic writtenCharacteristic = mCharacteristicCaptor.getValue();
+        assertThat(writtenCharacteristic.getUuid()).isEqualTo(CHARACTERISTIC_UUID);
+        assertThat(writtenCharacteristic).isEqualTo(characteristic);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_writeCharacteristic_by_uuid() throws Exception {
+        mBluetoothGattConnection.writeCharacteristic(SERVICE_UUID, CHARACTERISTIC_UUID, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeCharacteristic(mCharacteristicCaptor.capture(),
+                eq(DATA), anyInt());
+        BluetoothGattCharacteristic writtenCharacteristic = mCharacteristicCaptor.getValue();
+        assertThat(writtenCharacteristic.getUuid()).isEqualTo(CHARACTERISTIC_UUID);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_readDescriptor() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_DESCRIPTOR, mMockBluetoothGattWrapper,
+                        mMockBluetoothGattDescriptor),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result = mBluetoothGattConnection.readDescriptor(mMockBluetoothGattDescriptor);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).readDescriptor(mMockBluetoothGattDescriptor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_readDescriptor_by_uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<byte[]>(
+                        OperationType.READ_DESCRIPTOR, mMockBluetoothGattWrapper,
+                        mMockBluetoothGattDescriptor),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(DATA);
+
+        byte[] result =
+                mBluetoothGattConnection
+                        .readDescriptor(SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID);
+
+        assertThat(result).isEqualTo(DATA);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).readDescriptor(mMockBluetoothGattDescriptor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_writeDescriptor() throws Exception {
+        BluetoothGattDescriptor descriptor = new BluetoothGattDescriptor(DESCRIPTOR_UUID, 0);
+        mBluetoothGattConnection.writeDescriptor(descriptor, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeDescriptor(mDescriptorCaptor.capture(), eq(DATA));
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getUuid()).isEqualTo(DESCRIPTOR_UUID);
+        assertThat(writtenDescriptor).isEqualTo(descriptor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_writeDescriptor_by_uuid() throws Exception {
+        mBluetoothGattConnection.writeDescriptor(
+                SERVICE_UUID, CHARACTERISTIC_UUID, DESCRIPTOR_UUID, DATA);
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeDescriptor(mDescriptorCaptor.capture(), eq(DATA));
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getUuid()).isEqualTo(DESCRIPTOR_UUID);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_readRemoteRssi() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<Integer>(OperationType.READ_RSSI, mMockBluetoothGattWrapper),
+                BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS))
+                .thenReturn(RSSI);
+
+        int result = mBluetoothGattConnection.readRemoteRssi();
+
+        assertThat(result).isEqualTo(RSSI);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(
+                        mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).readRemoteRssi();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getMaxDataPacketSize() throws Exception {
+        int result = mBluetoothGattConnection.getMaxDataPacketSize();
+
+        assertThat(result).isEqualTo(mBluetoothGattConnection.getMtu() - 3);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSetNotificationEnabled_indication_enable() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(BluetoothGattCharacteristic.PROPERTY_INDICATE);
+
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, true);
+
+        verify(mMockBluetoothGattWrapper)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, true);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeDescriptor(mDescriptorCaptor.capture(),
+                eq(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE));
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_getNotificationEnabled_notification_enable() throws Exception {
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, true);
+
+        verify(mMockBluetoothGattWrapper)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, true);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeDescriptor(mDescriptorCaptor.capture(),
+                eq(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE));
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_setNotificationEnabled_indication_disable() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(BluetoothGattCharacteristic.PROPERTY_INDICATE);
+
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, false);
+
+        verify(mMockBluetoothGattWrapper)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, false);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeDescriptor(mDescriptorCaptor.capture(),
+                eq(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE));
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_setNotificationEnabled_notification_disable() throws Exception {
+        mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic, false);
+
+        verify(mMockBluetoothGattWrapper)
+                .setCharacteristicNotification(mMockBluetoothGattCharacteristic, false);
+        verify(mMockBluetoothOperationExecutor).execute(mOperationCaptor.capture(), anyLong());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).writeDescriptor(mDescriptorCaptor.capture(),
+                eq(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE));
+        BluetoothGattDescriptor writtenDescriptor = mDescriptorCaptor.getValue();
+        assertThat(writtenDescriptor.getUuid())
+                .isEqualTo(ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_setNotificationEnabled_failure() throws Exception {
+        when(mMockBluetoothGattCharacteristic.getProperties())
+                .thenReturn(BluetoothGattCharacteristic.PROPERTY_READ);
+
+        try {
+            mBluetoothGattConnection.setNotificationEnabled(mMockBluetoothGattCharacteristic,
+                    true);
+            fail("BluetoothException was expected");
+        } catch (BluetoothException expected) {
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_enableNotification_Uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor,
+                        OperationType.NOTIFICATION_CHANGE,
+                        mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection.enableNotification(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        ((ChangeObserver) mSynchronousOperationCaptor.getValue().call())
+                .setListener(mMockCharChangeListener);
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        verify(mMockCharChangeListener).onValueChange(DATA);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_enableNotification() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor,
+                        OperationType.NOTIFICATION_CHANGE,
+                        mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection.enableNotification(mMockBluetoothGattCharacteristic);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        ((ChangeObserver) mSynchronousOperationCaptor.getValue().call())
+                .setListener(mMockCharChangeListener);
+
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+
+        verify(mMockCharChangeListener).onValueChange(DATA);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_enableNotification_observe() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        mMockBluetoothOperationExecutor,
+                        OperationType.NOTIFICATION_CHANGE,
+                        mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection.enableNotification(mMockBluetoothGattCharacteristic);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        ChangeObserver changeObserver = (ChangeObserver) mSynchronousOperationCaptor.getValue()
+                .call();
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        assertThat(changeObserver.waitForUpdate(TimeUnit.SECONDS.toMillis(1))).isEqualTo(DATA);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_disableNotification_Uuid() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<>(
+                        OperationType.NOTIFICATION_CHANGE, mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection
+                .enableNotification(SERVICE_UUID, CHARACTERISTIC_UUID)
+                .setListener(mMockCharChangeListener);
+
+        mBluetoothGattConnection.disableNotification(SERVICE_UUID, CHARACTERISTIC_UUID);
+
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        verify(mMockCharChangeListener, never()).onValueChange(DATA);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_disableNotification() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new SynchronousOperation<ChangeObserver>(
+                        OperationType.NOTIFICATION_CHANGE, mMockBluetoothGattCharacteristic)))
+                .thenReturn(mMockChangeObserver);
+        mBluetoothGattConnection
+                .enableNotification(mMockBluetoothGattCharacteristic)
+                .setListener(mMockCharChangeListener);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+
+        mBluetoothGattConnection.disableNotification(mMockBluetoothGattCharacteristic);
+        verify(mMockBluetoothOperationExecutor).execute(mSynchronousOperationCaptor.capture());
+        mSynchronousOperationCaptor.getValue().call();
+
+        mBluetoothGattConnection.onCharacteristicChanged(mMockBluetoothGattCharacteristic, DATA);
+        verify(mMockCharChangeListener, never()).onValueChange(DATA);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_addCloseListener() throws Exception {
+        mBluetoothGattConnection.addCloseListener(mMockConnectionCloseListener);
+
+        mBluetoothGattConnection.onClosed();
+        verify(mMockConnectionCloseListener).onClose();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_removeCloseListener() throws Exception {
+        mBluetoothGattConnection.addCloseListener(mMockConnectionCloseListener);
+
+        mBluetoothGattConnection.removeCloseListener(mMockConnectionCloseListener);
+
+        mBluetoothGattConnection.onClosed();
+        verify(mMockConnectionCloseListener, never()).onClose();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_close() throws Exception {
+        mBluetoothGattConnection.close();
+
+        verify(mMockBluetoothOperationExecutor)
+                .execute(mOperationCaptor.capture(),
+                        eq(BluetoothGattConnection.OPERATION_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothGattWrapper).disconnect();
+        verify(mMockBluetoothGattWrapper).close();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_onClosed() throws Exception {
+        mBluetoothGattConnection.onClosed();
+
+        verify(mMockBluetoothOperationExecutor, never())
+                .execute(mOperationCaptor.capture(), anyLong());
+        verify(mMockBluetoothGattWrapper).close();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelperTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelperTest.java
new file mode 100644
index 0000000..7c20be1
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/gatt/BluetoothGattHelperTest.java
@@ -0,0 +1,675 @@
+/*
+ * 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.common.bluetooth.gatt;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+import android.test.mock.MockContext;
+
+import androidx.test.filters.SdkSuppress;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.ConnectionOptions;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.OperationType;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattWrapper;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.BluetoothLeScanner;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanCallback;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.le.ScanResult;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+
+import junit.framework.TestCase;
+
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.UUID;
+
+/**
+ * Unit tests for {@link BluetoothGattHelper}.
+ */
+public class BluetoothGattHelperTest extends TestCase {
+
+    private static final UUID SERVICE_UUID = UUID.randomUUID();
+    private static final int GATT_STATUS = 1234;
+    private static final Operation<BluetoothDevice> SCANNING_OPERATION =
+            new Operation<BluetoothDevice>(OperationType.SCAN);
+    private static final byte[] CHARACTERISTIC_VALUE = "characteristic_value".getBytes();
+    private static final byte[] DESCRIPTOR_VALUE = "descriptor_value".getBytes();
+    private static final int RSSI = -63;
+    private static final int MTU = 50;
+    private static final long CONNECT_TIMEOUT_MILLIS = 5000;
+
+    private Context mMockApplicationContext = new MockContext();
+    @Mock
+    private BluetoothAdapter mMockBluetoothAdapter;
+    @Mock
+    private BluetoothLeScanner mMockBluetoothLeScanner;
+    @Mock
+    private BluetoothOperationExecutor mMockBluetoothOperationExecutor;
+    @Mock
+    private BluetoothDevice mMockBluetoothDevice;
+    @Mock
+    private BluetoothGattConnection mMockBluetoothGattConnection;
+    @Mock
+    private BluetoothGattWrapper mMockBluetoothGattWrapper;
+    @Mock
+    private BluetoothGattCharacteristic mMockBluetoothGattCharacteristic;
+    @Mock
+    private BluetoothGattDescriptor mMockBluetoothGattDescriptor;
+    @Mock
+    private ScanResult mMockScanResult;
+
+    @Captor
+    private ArgumentCaptor<Operation<?>> mOperationCaptor;
+    @Captor
+    private ArgumentCaptor<ScanSettings> mScanSettingsCaptor;
+
+    private BluetoothGattHelper mBluetoothGattHelper;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        initMocks(this);
+
+        mBluetoothGattHelper = new BluetoothGattHelper(
+                mMockApplicationContext,
+                mMockBluetoothAdapter,
+                mMockBluetoothOperationExecutor);
+
+        when(mMockBluetoothAdapter.getBluetoothLeScanner()).thenReturn(mMockBluetoothLeScanner);
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION,
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS)).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION)).thenReturn(
+                mMockBluetoothDevice);
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, mMockBluetoothDevice),
+                CONNECT_TIMEOUT_MILLIS))
+                .thenReturn(mMockBluetoothGattConnection);
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT,
+                        mMockBluetoothDevice)))
+                .thenReturn(mMockBluetoothGattConnection);
+        when(mMockBluetoothGattCharacteristic.getValue()).thenReturn(CHARACTERISTIC_VALUE);
+        when(mMockBluetoothGattDescriptor.getValue()).thenReturn(DESCRIPTOR_VALUE);
+        when(mMockScanResult.getDevice()).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothGattWrapper.getDevice()).thenReturn(mMockBluetoothDevice);
+        when(mMockBluetoothDevice.connectGatt(eq(mMockApplicationContext), anyBoolean(),
+                eq(mBluetoothGattHelper.mBluetoothGattCallback))).thenReturn(
+                mMockBluetoothGattWrapper);
+        when(mMockBluetoothDevice.connectGatt(eq(mMockApplicationContext), anyBoolean(),
+                eq(mBluetoothGattHelper.mBluetoothGattCallback), anyInt()))
+                .thenReturn(mMockBluetoothGattWrapper);
+        when(mMockBluetoothGattConnection.getConnectionOptions())
+                .thenReturn(ConnectionOptions.builder().build());
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_autoConnect_uuid_success_lowLatency() throws Exception {
+        BluetoothGattConnection result = mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor, atLeastOnce())
+                .executeNonnull(mOperationCaptor.capture(),
+                        anyLong());
+        for (Operation<?> operation : mOperationCaptor.getAllValues()) {
+            operation.run();
+        }
+        verify(mMockBluetoothLeScanner).startScan(eq(Arrays.asList(
+                new ScanFilter.Builder().setServiceUuid(new ParcelUuid(SERVICE_UUID)).build())),
+                mScanSettingsCaptor.capture(), eq(mBluetoothGattHelper.mScanCallback));
+        assertThat(mScanSettingsCaptor.getValue().getScanMode()).isEqualTo(
+                ScanSettings.SCAN_MODE_LOW_LATENCY);
+        verify(mMockBluetoothLeScanner).stopScan(mBluetoothGattHelper.mScanCallback);
+        verifyNoMoreInteractions(mMockBluetoothLeScanner);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_autoConnect_uuid_success_lowPower() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION,
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS)).thenThrow(
+                new BluetoothOperationTimeoutException("Timeout"));
+
+        BluetoothGattConnection result = mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor).executeNonnull(mOperationCaptor.capture());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothLeScanner).startScan(eq(Arrays.asList(
+                new ScanFilter.Builder().setServiceUuid(new ParcelUuid(SERVICE_UUID)).build())),
+                mScanSettingsCaptor.capture(), eq(mBluetoothGattHelper.mScanCallback));
+        assertThat(mScanSettingsCaptor.getValue().getScanMode()).isEqualTo(
+                ScanSettings.SCAN_MODE_LOW_POWER);
+        verify(mMockBluetoothLeScanner, times(2)).stopScan(mBluetoothGattHelper.mScanCallback);
+        verifyNoMoreInteractions(mMockBluetoothLeScanner);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_autoConnect_uuid_success_afterRetry() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, mMockBluetoothDevice),
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS))
+                .thenThrow(new BluetoothException("first attempt fails!"))
+                .thenReturn(mMockBluetoothGattConnection);
+
+        BluetoothGattConnection result = mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_autoConnect_uuid_failure_scanning() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(SCANNING_OPERATION,
+                BluetoothGattHelper.LOW_LATENCY_SCAN_MILLIS)).thenThrow(
+                new BluetoothException("Scanning failed"));
+
+        try {
+            mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_autoConnect_uuid_failure_connecting() throws Exception {
+        when(mMockBluetoothOperationExecutor.executeNonnull(
+                new Operation<BluetoothGattConnection>(OperationType.CONNECT, mMockBluetoothDevice),
+                CONNECT_TIMEOUT_MILLIS))
+                .thenThrow(new BluetoothException("Connect failed"));
+
+        try {
+            mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+        verify(mMockBluetoothOperationExecutor, times(3))
+                .executeNonnull(
+                        new Operation<BluetoothGattConnection>(OperationType.CONNECT,
+                                mMockBluetoothDevice),
+                        CONNECT_TIMEOUT_MILLIS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_autoConnect_uuid_failure_noBle() throws Exception {
+        when(mMockBluetoothAdapter.getBluetoothLeScanner()).thenReturn(null);
+
+        try {
+            mBluetoothGattHelper.autoConnect(SERVICE_UUID);
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_connect() throws Exception {
+        BluetoothGattConnection result = mBluetoothGattHelper.connect(mMockBluetoothDevice);
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), eq(CONNECT_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothDevice).connectGatt(mMockApplicationContext, false,
+                mBluetoothGattHelper.mBluetoothGattCallback,
+                android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper).getDevice())
+                .isEqualTo(mMockBluetoothDevice);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_connect_withOptionAutoConnect_success() throws Exception {
+        BluetoothGattConnection result = mBluetoothGattHelper
+                .connect(
+                        mMockBluetoothDevice,
+                        ConnectionOptions.builder()
+                                .setAutoConnect(true)
+                                .build());
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor).executeNonnull(mOperationCaptor.capture());
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothDevice).connectGatt(mMockApplicationContext, true,
+                mBluetoothGattHelper.mBluetoothGattCallback,
+                android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)
+                .getConnectionOptions())
+                .isEqualTo(ConnectionOptions.builder()
+                        .setAutoConnect(true)
+                        .build());
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_connect_withOptionAutoConnect_failure_nullResult() throws Exception {
+        when(mMockBluetoothDevice.connectGatt(eq(mMockApplicationContext), anyBoolean(),
+                eq(mBluetoothGattHelper.mBluetoothGattCallback),
+                eq(android.bluetooth.BluetoothDevice.TRANSPORT_LE))).thenReturn(null);
+
+        try {
+            mBluetoothGattHelper.connect(
+                    mMockBluetoothDevice,
+                    ConnectionOptions.builder()
+                            .setAutoConnect(true)
+                            .build());
+            verify(mMockBluetoothOperationExecutor).executeNonnull(mOperationCaptor.capture());
+            mOperationCaptor.getValue().run();
+            fail("BluetoothException expected");
+        } catch (BluetoothException e) {
+            // expected
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_connect_withOptionRequestConnectionPriority_success() throws Exception {
+        // Operation succeeds on the 3rd try.
+        when(mMockBluetoothGattWrapper
+                .requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH))
+                .thenReturn(false)
+                .thenReturn(false)
+                .thenReturn(true);
+
+        BluetoothGattConnection result = mBluetoothGattHelper
+                .connect(
+                        mMockBluetoothDevice,
+                        ConnectionOptions.builder()
+                                .setConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
+                                .build());
+
+        assertThat(result).isEqualTo(mMockBluetoothGattConnection);
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), eq(CONNECT_TIMEOUT_MILLIS));
+        mOperationCaptor.getValue().run();
+        verify(mMockBluetoothDevice).connectGatt(mMockApplicationContext, false,
+                mBluetoothGattHelper.mBluetoothGattCallback,
+                android.bluetooth.BluetoothDevice.TRANSPORT_LE);
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)
+                .getConnectionOptions())
+                .isEqualTo(ConnectionOptions.builder()
+                        .setConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
+                        .build());
+        verify(mMockBluetoothGattWrapper, times(3))
+                .requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_connect_cancel() throws Exception {
+        mBluetoothGattHelper.connect(mMockBluetoothDevice);
+
+        verify(mMockBluetoothOperationExecutor)
+                .executeNonnull(mOperationCaptor.capture(), eq(CONNECT_TIMEOUT_MILLIS));
+        Operation<?> operation = mOperationCaptor.getValue();
+        operation.run();
+        operation.cancel();
+
+        verify(mMockBluetoothGattWrapper).disconnect();
+        verify(mMockBluetoothGattWrapper).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_success()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(
+                mMockBluetoothGattWrapper,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                BluetoothGatt.GATT_SUCCESS,
+                mMockBluetoothGattConnection);
+        verify(mMockBluetoothGattConnection).onConnected();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_success_withMtuOption()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.getConnectionOptions())
+                .thenReturn(BluetoothGattHelper.ConnectionOptions.builder()
+                        .setMtu(MTU)
+                        .build());
+        when(mMockBluetoothGattWrapper.requestMtu(MTU)).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(
+                mMockBluetoothGattWrapper,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verifyZeroInteractions(mMockBluetoothOperationExecutor);
+        verify(mMockBluetoothGattConnection, never()).onConnected();
+        verify(mMockBluetoothGattWrapper).requestMtu(MTU);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_success_failMtuOption()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.getConnectionOptions())
+                .thenReturn(BluetoothGattHelper.ConnectionOptions.builder()
+                        .setMtu(MTU)
+                        .build());
+        when(mMockBluetoothGattWrapper.requestMtu(MTU)).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(
+                mMockBluetoothGattWrapper,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyFailure(
+                eq(new Operation<>(OperationType.CONNECT, mMockBluetoothDevice)),
+                any(BluetoothException.class));
+        verify(mMockBluetoothGattConnection, never()).onConnected();
+        verify(mMockBluetoothGattWrapper).disconnect();
+        verify(mMockBluetoothGattWrapper).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_unexpectedSuccess()
+            throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(
+                mMockBluetoothGattWrapper,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_CONNECTED);
+
+        verifyZeroInteractions(mMockBluetoothOperationExecutor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_connected_failure()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onConnectionStateChange(
+                        mMockBluetoothGattWrapper,
+                        BluetoothGatt.GATT_FAILURE,
+                        BluetoothGatt.STATE_CONNECTED);
+
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        BluetoothGatt.GATT_FAILURE,
+                        null);
+        verify(mMockBluetoothGattWrapper).disconnect();
+        verify(mMockBluetoothGattWrapper).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_unexpectedSuccess()
+            throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onConnectionStateChange(
+                        mMockBluetoothGattWrapper,
+                        BluetoothGatt.GATT_SUCCESS,
+                        BluetoothGatt.STATE_DISCONNECTED);
+
+        verifyZeroInteractions(mMockBluetoothOperationExecutor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_notConnected()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onConnectionStateChange(
+                        mMockBluetoothGattWrapper,
+                        GATT_STATUS,
+                        BluetoothGatt.STATE_DISCONNECTED);
+
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        GATT_STATUS,
+                        null);
+        verify(mMockBluetoothGattWrapper).disconnect();
+        verify(mMockBluetoothGattWrapper).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_success()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(
+                mMockBluetoothGattWrapper,
+                BluetoothGatt.GATT_SUCCESS, BluetoothGatt.STATE_DISCONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.DISCONNECT, mMockBluetoothDevice),
+                BluetoothGatt.GATT_SUCCESS);
+        verify(mMockBluetoothGattConnection).onClosed();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onConnectionStateChange_disconnected_failure()
+            throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onConnectionStateChange(
+                mMockBluetoothGattWrapper,
+                BluetoothGatt.GATT_FAILURE, BluetoothGatt.STATE_DISCONNECTED);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.DISCONNECT, mMockBluetoothDevice),
+                BluetoothGatt.GATT_FAILURE);
+        verify(mMockBluetoothGattConnection).onClosed();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onServicesDiscovered() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onServicesDiscovered(mMockBluetoothGattWrapper,
+                GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<Void>(OperationType.DISCOVER_SERVICES_INTERNAL,
+                        mMockBluetoothGattWrapper),
+                GATT_STATUS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onCharacteristicRead() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onCharacteristicRead(mMockBluetoothGattWrapper,
+                mMockBluetoothGattCharacteristic, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<byte[]>(
+                        OperationType.READ_CHARACTERISTIC, mMockBluetoothGattWrapper,
+                        mMockBluetoothGattCharacteristic),
+                GATT_STATUS, CHARACTERISTIC_VALUE);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onCharacteristicWrite() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onCharacteristicWrite(mMockBluetoothGattWrapper,
+                mMockBluetoothGattCharacteristic, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<Void>(
+                        OperationType.WRITE_CHARACTERISTIC, mMockBluetoothGattWrapper,
+                        mMockBluetoothGattCharacteristic),
+                GATT_STATUS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onDescriptorRead() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onDescriptorRead(mMockBluetoothGattWrapper,
+                mMockBluetoothGattDescriptor, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<byte[]>(
+                        OperationType.READ_DESCRIPTOR, mMockBluetoothGattWrapper,
+                        mMockBluetoothGattDescriptor),
+                GATT_STATUS,
+                DESCRIPTOR_VALUE);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onDescriptorWrite() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onDescriptorWrite(mMockBluetoothGattWrapper,
+                mMockBluetoothGattDescriptor, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(new Operation<Void>(
+                        OperationType.WRITE_DESCRIPTOR, mMockBluetoothGattWrapper,
+                        mMockBluetoothGattDescriptor),
+                GATT_STATUS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onReadRemoteRssi() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onReadRemoteRssi(mMockBluetoothGattWrapper,
+                RSSI, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<Integer>(OperationType.READ_RSSI, mMockBluetoothGattWrapper),
+                GATT_STATUS, RSSI);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onReliableWriteCompleted() throws Exception {
+        mBluetoothGattHelper.mBluetoothGattCallback.onReliableWriteCompleted(
+                mMockBluetoothGattWrapper,
+                GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<Void>(OperationType.WRITE_RELIABLE, mMockBluetoothGattWrapper),
+                GATT_STATUS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onMtuChanged() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(true);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onMtuChanged(mMockBluetoothGattWrapper, MTU, GATT_STATUS);
+
+        verify(mMockBluetoothOperationExecutor).notifyCompletion(
+                new Operation<>(OperationType.CHANGE_MTU, mMockBluetoothGattWrapper), GATT_STATUS,
+                MTU);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothGattCallback_onMtuChangedDuringConnection_success() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onMtuChanged(
+                mMockBluetoothGattWrapper, MTU, BluetoothGatt.GATT_SUCCESS);
+
+        verify(mMockBluetoothGattConnection).onConnected();
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        BluetoothGatt.GATT_SUCCESS,
+                        mMockBluetoothGattConnection);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testBluetoothGattCallback_onMtuChangedDuringConnection_fail() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+        when(mMockBluetoothGattConnection.isConnected()).thenReturn(false);
+
+        mBluetoothGattHelper.mBluetoothGattCallback
+                .onMtuChanged(mMockBluetoothGattWrapper, MTU, GATT_STATUS);
+
+        verify(mMockBluetoothGattConnection).onConnected();
+        verify(mMockBluetoothOperationExecutor)
+                .notifyCompletion(
+                        new Operation<>(OperationType.CONNECT, mMockBluetoothDevice),
+                        GATT_STATUS,
+                        mMockBluetoothGattConnection);
+        verify(mMockBluetoothGattWrapper).disconnect();
+        verify(mMockBluetoothGattWrapper).close();
+        assertThat(mBluetoothGattHelper.mConnections.get(mMockBluetoothGattWrapper)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_BluetoothGattCallback_onCharacteristicChanged() throws Exception {
+        mBluetoothGattHelper.mConnections.put(mMockBluetoothGattWrapper,
+                mMockBluetoothGattConnection);
+
+        mBluetoothGattHelper.mBluetoothGattCallback.onCharacteristicChanged(
+                mMockBluetoothGattWrapper,
+                mMockBluetoothGattCharacteristic);
+
+        verify(mMockBluetoothGattConnection).onCharacteristicChanged(
+                mMockBluetoothGattCharacteristic,
+                CHARACTERISTIC_VALUE);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_ScanCallback_onScanFailed() throws Exception {
+        mBluetoothGattHelper.mScanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR);
+
+        verify(mMockBluetoothOperationExecutor).notifyFailure(
+                eq(new Operation<BluetoothDevice>(OperationType.SCAN)),
+                isA(BluetoothException.class));
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_ScanCallback_onScanResult() throws Exception {
+        mBluetoothGattHelper.mScanCallback.onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES,
+                mMockScanResult);
+
+        verify(mMockBluetoothOperationExecutor).notifySuccess(
+                new Operation<BluetoothDevice>(OperationType.SCAN), mMockBluetoothDevice);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtilsTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtilsTest.java
new file mode 100644
index 0000000..47182c3
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtilsTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.common.bluetooth.util;
+
+import static com.android.server.nearby.common.bluetooth.util.BluetoothGattUtils.getMessageForStatusCode;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.bluetooth.BluetoothGatt;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.UUID;
+
+/** Unit tests for {@link BluetoothGattUtils}. */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class BluetoothGattUtilsTest {
+    private static final UUID TEST_UUID = UUID.randomUUID();
+    private static final ImmutableSet<String> GATT_HIDDEN_CONSTANTS = ImmutableSet.of(
+            "GATT_WRITE_REQUEST_BUSY", "GATT_WRITE_REQUEST_FAIL", "GATT_WRITE_REQUEST_SUCCESS");
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetMessageForStatusCode() throws Exception {
+        Field[] publicFields = BluetoothGatt.class.getFields();
+        for (Field field : publicFields) {
+            if ((field.getModifiers() & Modifier.STATIC) == 0
+                    || field.getDeclaringClass() != BluetoothGatt.class) {
+                continue;
+            }
+            String fieldName = field.getName();
+            if (!fieldName.startsWith("GATT_") || GATT_HIDDEN_CONSTANTS.contains(fieldName)) {
+                continue;
+            }
+            int fieldValue = (Integer) field.get(null);
+            assertThat(getMessageForStatusCode(fieldValue)).isEqualTo(fieldName);
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/util/BluetoothOperationExecutorTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/util/BluetoothOperationExecutorTest.java
new file mode 100644
index 0000000..7b3ebab
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/bluetooth/util/BluetoothOperationExecutorTest.java
@@ -0,0 +1,321 @@
+/*
+ * 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.common.bluetooth.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothGatt;
+
+import androidx.test.filters.SdkSuppress;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.testability.NonnullProvider;
+import com.android.server.nearby.common.bluetooth.testability.TimeProvider;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.SynchronousOperation;
+
+import junit.framework.TestCase;
+
+import org.mockito.Mock;
+
+import java.util.Arrays;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+
+/**
+ * Unit tests for {@link BluetoothOperationExecutor}.
+ */
+public class BluetoothOperationExecutorTest extends TestCase {
+
+    private static final String OPERATION_RESULT = "result";
+    private static final String EXCEPTION_REASON = "exception";
+    private static final long TIME = 1234;
+    private static final long TIMEOUT = 121212;
+
+    @Mock
+    private NonnullProvider<BlockingQueue<Object>> mMockBlockingQueueProvider;
+    @Mock
+    private TimeProvider mMockTimeProvider;
+    @Mock
+    private BlockingQueue<Object> mMockBlockingQueue;
+    @Mock
+    private Semaphore mMockSemaphore;
+    @Mock
+    private Operation<String> mMockStringOperation;
+    @Mock
+    private Operation<Void> mMockVoidOperation;
+    @Mock
+    private Future<Object> mMockFuture;
+    @Mock
+    private Future<Object> mMockFuture2;
+
+    private BluetoothOperationExecutor mBluetoothOperationExecutor;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        initMocks(this);
+
+        when(mMockBlockingQueueProvider.get()).thenReturn(mMockBlockingQueue);
+        when(mMockSemaphore.tryAcquire()).thenReturn(true);
+        when(mMockTimeProvider.getTimeMillis()).thenReturn(TIME);
+
+        mBluetoothOperationExecutor =
+                new BluetoothOperationExecutor(mMockSemaphore, mMockTimeProvider,
+                        mMockBlockingQueueProvider);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testExecute() throws Exception {
+        when(mMockBlockingQueue.take()).thenReturn(OPERATION_RESULT);
+
+        String result = mBluetoothOperationExecutor.execute(mMockStringOperation);
+
+        verify(mMockStringOperation).execute(mBluetoothOperationExecutor);
+        assertThat(result).isEqualTo(OPERATION_RESULT);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testExecuteWithTimeout() throws Exception {
+        when(mMockBlockingQueue.poll(TIMEOUT, TimeUnit.MILLISECONDS)).thenReturn(OPERATION_RESULT);
+
+        String result = mBluetoothOperationExecutor.execute(mMockStringOperation, TIMEOUT);
+
+        verify(mMockStringOperation).execute(mBluetoothOperationExecutor);
+        assertThat(result).isEqualTo(OPERATION_RESULT);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testSchedule() throws Exception {
+        when(mMockBlockingQueue.poll(TIMEOUT, TimeUnit.MILLISECONDS)).thenReturn(OPERATION_RESULT);
+
+        Future<String> result = mBluetoothOperationExecutor.schedule(mMockStringOperation);
+
+        verify(mMockStringOperation).execute(mBluetoothOperationExecutor);
+        assertThat(result.get(TIMEOUT, TimeUnit.MILLISECONDS)).isEqualTo(OPERATION_RESULT);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testScheduleOtherOperationInProgress() throws Exception {
+        when(mMockSemaphore.tryAcquire()).thenReturn(false);
+        when(mMockBlockingQueue.poll(TIMEOUT, TimeUnit.MILLISECONDS)).thenReturn(OPERATION_RESULT);
+
+        Future<String> result = mBluetoothOperationExecutor.schedule(mMockStringOperation);
+
+        verify(mMockStringOperation, never()).run();
+
+        when(mMockSemaphore.tryAcquire(TIMEOUT, TimeUnit.MILLISECONDS)).thenReturn(true);
+
+        assertThat(result.get(TIMEOUT, TimeUnit.MILLISECONDS)).isEqualTo(OPERATION_RESULT);
+        verify(mMockStringOperation).execute(mBluetoothOperationExecutor);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNotifySuccessWithResult() throws Exception {
+        when(mMockBlockingQueueProvider.get()).thenReturn(new LinkedBlockingDeque<Object>());
+        Future<String> future = mBluetoothOperationExecutor.schedule(mMockStringOperation);
+
+        mBluetoothOperationExecutor.notifySuccess(mMockStringOperation, OPERATION_RESULT);
+
+        assertThat(future.get(1, TimeUnit.MILLISECONDS)).isEqualTo(OPERATION_RESULT);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNotifySuccessTwice() throws Exception {
+        BlockingQueue<Object> resultQueue = new LinkedBlockingDeque<Object>();
+        when(mMockBlockingQueueProvider.get()).thenReturn(resultQueue);
+        Future<String> future = mBluetoothOperationExecutor.schedule(mMockStringOperation);
+
+        mBluetoothOperationExecutor.notifySuccess(mMockStringOperation, OPERATION_RESULT);
+
+        assertThat(future.get(1, TimeUnit.MILLISECONDS)).isEqualTo(OPERATION_RESULT);
+
+        // the second notification should be ignored
+        mBluetoothOperationExecutor.notifySuccess(mMockStringOperation, OPERATION_RESULT);
+        assertThat(resultQueue).isEmpty();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNotifySuccessWithNullResult() throws Exception {
+        when(mMockBlockingQueueProvider.get()).thenReturn(new LinkedBlockingDeque<Object>());
+        Future<String> future = mBluetoothOperationExecutor.schedule(mMockStringOperation);
+
+        mBluetoothOperationExecutor.notifySuccess(mMockStringOperation, null);
+
+        assertThat(future.get(1, TimeUnit.MILLISECONDS)).isNull();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNotifySuccess() throws Exception {
+        when(mMockBlockingQueueProvider.get()).thenReturn(new LinkedBlockingDeque<Object>());
+        Future<Void> future = mBluetoothOperationExecutor.schedule(mMockVoidOperation);
+
+        mBluetoothOperationExecutor.notifySuccess(mMockVoidOperation);
+
+        future.get(1, TimeUnit.MILLISECONDS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNotifyCompletionSuccess() throws Exception {
+        when(mMockBlockingQueueProvider.get()).thenReturn(new LinkedBlockingDeque<Object>());
+        Future<Void> future = mBluetoothOperationExecutor.schedule(mMockVoidOperation);
+
+        mBluetoothOperationExecutor
+                .notifyCompletion(mMockVoidOperation, BluetoothGatt.GATT_SUCCESS);
+
+        future.get(1, TimeUnit.MILLISECONDS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNotifyCompletionFailure() throws Exception {
+        when(mMockBlockingQueueProvider.get()).thenReturn(new LinkedBlockingDeque<Object>());
+        Future<Void> future = mBluetoothOperationExecutor.schedule(mMockVoidOperation);
+
+        mBluetoothOperationExecutor
+                .notifyCompletion(mMockVoidOperation, BluetoothGatt.GATT_FAILURE);
+
+        try {
+            BluetoothOperationExecutor.getResult(future, 1);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException e) {
+            //expected
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testNotifyFailure() throws Exception {
+        when(mMockBlockingQueueProvider.get()).thenReturn(new LinkedBlockingDeque<Object>());
+        Future<Void> future = mBluetoothOperationExecutor.schedule(mMockVoidOperation);
+
+        mBluetoothOperationExecutor
+                .notifyFailure(mMockVoidOperation, new BluetoothException("test"));
+
+        try {
+            BluetoothOperationExecutor.getResult(future, 1);
+            fail("Expected BluetoothException");
+        } catch (BluetoothException e) {
+            //expected
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWaitFor() throws Exception {
+        mBluetoothOperationExecutor.waitFor(Arrays.asList(mMockFuture, mMockFuture2));
+
+        verify(mMockFuture).get();
+        verify(mMockFuture2).get();
+    }
+
+    @SuppressWarnings("unchecked")
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testWaitForWithTimeout() throws Exception {
+        mBluetoothOperationExecutor.waitFor(
+                Arrays.asList(mMockFuture, mMockFuture2),
+                TIMEOUT);
+
+        verify(mMockFuture).get(TIMEOUT, TimeUnit.MILLISECONDS);
+        verify(mMockFuture2).get(TIMEOUT, TimeUnit.MILLISECONDS);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetResult() throws Exception {
+        when(mMockFuture.get()).thenReturn(OPERATION_RESULT);
+
+        Object result = BluetoothOperationExecutor.getResult(mMockFuture);
+
+        assertThat(result).isEqualTo(OPERATION_RESULT);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testGetResultWithTimeout() throws Exception {
+        when(mMockFuture.get(TIMEOUT, TimeUnit.MILLISECONDS)).thenThrow(new TimeoutException());
+
+        try {
+            BluetoothOperationExecutor.getResult(mMockFuture, TIMEOUT);
+            fail("Expected BluetoothOperationTimeoutException");
+        } catch (BluetoothOperationTimeoutException e) {
+            //expected
+        }
+        verify(mMockFuture).cancel(true);
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_SynchronousOperation_execute() throws Exception {
+        when(mMockBlockingQueueProvider.get()).thenReturn(mMockBlockingQueue);
+        SynchronousOperation<String> synchronousOperation = new SynchronousOperation<String>() {
+            @Override
+            public String call() throws BluetoothException {
+                return OPERATION_RESULT;
+            }
+        };
+
+        @SuppressWarnings("unused") // future return.
+        Future<?> possiblyIgnoredError = mBluetoothOperationExecutor.schedule(synchronousOperation);
+
+        verify(mMockBlockingQueue).add(OPERATION_RESULT);
+        verify(mMockSemaphore).release();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_SynchronousOperation_exception() throws Exception {
+        final BluetoothException exception = new BluetoothException(EXCEPTION_REASON);
+        when(mMockBlockingQueueProvider.get()).thenReturn(mMockBlockingQueue);
+        SynchronousOperation<String> synchronousOperation = new SynchronousOperation<String>() {
+            @Override
+            public String call() throws BluetoothException {
+                throw exception;
+            }
+        };
+
+        @SuppressWarnings("unused") // future return.
+        Future<?> possiblyIgnoredError = mBluetoothOperationExecutor.schedule(synchronousOperation);
+
+        verify(mMockBlockingQueue).add(exception);
+        verify(mMockSemaphore).release();
+    }
+
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void test_AsynchronousOperation_exception() throws Exception {
+        final BluetoothException exception = new BluetoothException(EXCEPTION_REASON);
+        when(mMockBlockingQueueProvider.get()).thenReturn(mMockBlockingQueue);
+        Operation<String> operation = new Operation<String>() {
+            @Override
+            public void run() throws BluetoothException {
+                throw exception;
+            }
+        };
+
+        @SuppressWarnings("unused") // future return.
+        Future<?> possiblyIgnoredError = mBluetoothOperationExecutor.schedule(operation);
+
+        verify(mMockBlockingQueue).add(exception);
+        verify(mMockSemaphore).release();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/eventloop/EventLoopTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/eventloop/EventLoopTest.java
new file mode 100644
index 0000000..70dcec8
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/eventloop/EventLoopTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.common.eventloop;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.util.ArrayList;
+import java.util.List;
+
+
+public class EventLoopTest {
+    private static final String TAG = "EventLoopTest";
+
+    private final EventLoop mEventLoop = EventLoop.newInstance(TAG);
+    private final List<Integer> mExecutedRunnables = new ArrayList<>();
+    @Rule
+    public ExpectedException thrown = ExpectedException.none();
+
+    /*
+    @Test
+    public void remove() {
+        mEventLoop.postRunnable(new NumberedRunnable(0));
+        NumberedRunnable runnableToAddAndRemove = new NumberedRunnable(1);
+        mEventLoop.postRunnable(runnableToAddAndRemove);
+        mEventLoop.removeRunnable(runnableToAddAndRemove);
+        mEventLoop.postRunnable(new NumberedRunnable(2));
+
+        assertThat(mExecutedRunnables).containsExactly(0, 2);
+    }
+    */
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void isPosted() {
+        NumberedRunnable runnable = new NumberedRunnable(0);
+        mEventLoop.postRunnableDelayed(runnable, 10 * 1000L);
+        assertThat(mEventLoop.isPosted(runnable)).isTrue();
+        mEventLoop.removeRunnable(runnable);
+        assertThat(mEventLoop.isPosted(runnable)).isFalse();
+
+        // Let a runnable execute, then verify that it's not posted.
+        mEventLoop.postRunnable(runnable);
+        assertThat(mEventLoop.isPosted(runnable)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void postAndWaitAfterDestroy() throws InterruptedException {
+        mEventLoop.destroy();
+        mEventLoop.postAndWait(new NumberedRunnable(0));
+
+        assertThat(mExecutedRunnables).isEmpty();
+    }
+
+
+    private class NumberedRunnable extends NamedRunnable {
+        private final int mId;
+
+        private NumberedRunnable(int id) {
+            super("NumberedRunnable:" + id);
+            this.mId = id;
+        }
+
+        @Override
+        public void run() {
+            // Note: when running in robolectric, this is not actually executed on a different
+            // thread, it's executed in the same thread the test runs in, so this is safe.
+            mExecutedRunnables.add(mId);
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/FastPairManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/FastPairManagerTest.java
new file mode 100644
index 0000000..26d1847
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/FastPairManagerTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.fastpair;
+
+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 androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+
+public class FastPairManagerTest {
+    private FastPairManager mFastPairManager;
+    @Mock private Context mContext;
+    private LocatorContextWrapper mLocatorContextWrapper;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+
+        mLocatorContextWrapper = new LocatorContextWrapper(mContext);
+        mFastPairManager = new FastPairManager(mLocatorContextWrapper);
+        when(mContext.getContentResolver()).thenReturn(
+                InstrumentationRegistry.getInstrumentation().getContext().getContentResolver());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testFastPairInit() {
+        mFastPairManager.initiate();
+
+        verify(mContext, times(1)).registerReceiver(any(), any());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testFastPairCleanUp() {
+        mFastPairManager.cleanUp();
+
+        verify(mContext, times(1)).unregisterReceiver(any());
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/ModuleTest.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/ModuleTest.java
new file mode 100644
index 0000000..bb4e3d0
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/ModuleTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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 src.com.android.server.nearby.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.SdkSuppress;
+
+import com.android.server.nearby.common.eventloop.EventLoop;
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.fastpair.FastPairAdvHandler;
+import com.android.server.nearby.fastpair.FastPairModule;
+import com.android.server.nearby.fastpair.cache.FastPairCacheManager;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockitoAnnotations;
+
+import java.time.Clock;
+
+import src.com.android.server.nearby.fastpair.testing.MockingLocator;
+
+public class ModuleTest {
+    private Locator mLocator;
+
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mLocator = MockingLocator.withMocksOnly(ApplicationProvider.getApplicationContext());
+        mLocator.bind(new FastPairModule());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void genericConstructor() {
+        assertThat(mLocator.get(FastPairCacheManager.class)).isNotNull();
+        assertThat(mLocator.get(FootprintsDeviceManager.class)).isNotNull();
+        assertThat(mLocator.get(EventLoop.class)).isNotNull();
+        assertThat(mLocator.get(FastPairHalfSheetManager.class)).isNotNull();
+        assertThat(mLocator.get(FastPairAdvHandler.class)).isNotNull();
+        assertThat(mLocator.get(Clock.class)).isNotNull();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void genericDestroy() {
+        mLocator.destroy();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/cache/FastPairCacheManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/cache/FastPairCacheManagerTest.java
new file mode 100644
index 0000000..adae97d
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/cache/FastPairCacheManagerTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.fastpair.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.SdkSuppress;
+
+import com.google.protobuf.ByteString;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import service.proto.Cache;
+
+public class FastPairCacheManagerTest {
+
+    private static final String MODEL_ID = "001";
+    private static final String MODEL_ID2 = "002";
+    private static final String APP_NAME = "APP_NAME";
+    private static final String MAC_ADDRESS = "00:11:22:33";
+    private static final ByteString ACCOUNT_KEY = ByteString.copyFromUtf8("axgs");
+    private static final String MAC_ADDRESS_B = "00:11:22:44";
+    private static final ByteString ACCOUNT_KEY_B = ByteString.copyFromUtf8("axgb");
+
+    @Mock
+    DiscoveryItem mDiscoveryItem;
+    @Mock
+    DiscoveryItem mDiscoveryItem2;
+    @Mock
+    Cache.StoredFastPairItem mStoredFastPairItem;
+    Cache.StoredDiscoveryItem mStoredDiscoveryItem = Cache.StoredDiscoveryItem.newBuilder()
+            .setTriggerId(MODEL_ID)
+            .setAppName(APP_NAME).build();
+    Cache.StoredDiscoveryItem mStoredDiscoveryItem2 = Cache.StoredDiscoveryItem.newBuilder()
+            .setTriggerId(MODEL_ID2)
+            .setAppName(APP_NAME).build();
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void notSaveRetrieveInfo() {
+        Context mContext = ApplicationProvider.getApplicationContext();
+        when(mDiscoveryItem.getCopyOfStoredItem()).thenReturn(mStoredDiscoveryItem);
+        when(mDiscoveryItem.getTriggerId()).thenReturn(MODEL_ID);
+
+        FastPairCacheManager fastPairCacheManager = new FastPairCacheManager(mContext);
+
+        assertThat(fastPairCacheManager.getStoredDiscoveryItem(MODEL_ID).getAppName())
+                .isNotEqualTo(APP_NAME);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void saveRetrieveInfo() {
+        Context mContext = ApplicationProvider.getApplicationContext();
+        when(mDiscoveryItem.getCopyOfStoredItem()).thenReturn(mStoredDiscoveryItem);
+        when(mDiscoveryItem.getTriggerId()).thenReturn(MODEL_ID);
+
+        FastPairCacheManager fastPairCacheManager = new FastPairCacheManager(mContext);
+        fastPairCacheManager.saveDiscoveryItem(mDiscoveryItem);
+        assertThat(fastPairCacheManager.getStoredDiscoveryItem(MODEL_ID).getAppName())
+                .isEqualTo(APP_NAME);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void getAllInfo() {
+        Context mContext = ApplicationProvider.getApplicationContext();
+        when(mDiscoveryItem.getCopyOfStoredItem()).thenReturn(mStoredDiscoveryItem);
+        when(mDiscoveryItem.getTriggerId()).thenReturn(MODEL_ID);
+        when(mDiscoveryItem2.getCopyOfStoredItem()).thenReturn(mStoredDiscoveryItem2);
+        when(mDiscoveryItem2.getTriggerId()).thenReturn(MODEL_ID2);
+
+        FastPairCacheManager fastPairCacheManager = new FastPairCacheManager(mContext);
+        fastPairCacheManager.saveDiscoveryItem(mDiscoveryItem);
+
+        assertThat(fastPairCacheManager.getAllSavedStoreDiscoveryItem()).hasSize(2);
+
+        fastPairCacheManager.saveDiscoveryItem(mDiscoveryItem2);
+
+        assertThat(fastPairCacheManager.getAllSavedStoreDiscoveryItem()).hasSize(3);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void saveRetrieveInfoStoredFastPairItem() {
+        Context mContext = ApplicationProvider.getApplicationContext();
+        Cache.StoredFastPairItem storedFastPairItem = Cache.StoredFastPairItem.newBuilder()
+                .setMacAddress(MAC_ADDRESS)
+                .setAccountKey(ACCOUNT_KEY)
+                .build();
+
+
+        FastPairCacheManager fastPairCacheManager = new FastPairCacheManager(mContext);
+        fastPairCacheManager.putStoredFastPairItem(storedFastPairItem);
+
+        assertThat(fastPairCacheManager.getStoredFastPairItemFromMacAddress(
+                MAC_ADDRESS).getAccountKey())
+                .isEqualTo(ACCOUNT_KEY);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void checkGetAllFastPairItems() {
+        Context mContext = ApplicationProvider.getApplicationContext();
+        Cache.StoredFastPairItem storedFastPairItem = Cache.StoredFastPairItem.newBuilder()
+                .setMacAddress(MAC_ADDRESS)
+                .setAccountKey(ACCOUNT_KEY)
+                .build();
+        Cache.StoredFastPairItem storedFastPairItemB = Cache.StoredFastPairItem.newBuilder()
+                .setMacAddress(MAC_ADDRESS_B)
+                .setAccountKey(ACCOUNT_KEY_B)
+                .build();
+
+        FastPairCacheManager fastPairCacheManager = new FastPairCacheManager(mContext);
+        fastPairCacheManager.putStoredFastPairItem(storedFastPairItem);
+        fastPairCacheManager.putStoredFastPairItem(storedFastPairItemB);
+
+        assertThat(fastPairCacheManager.getAllSavedStoredFastPairItem().size())
+                .isEqualTo(2);
+
+        fastPairCacheManager.removeStoredFastPairItem(MAC_ADDRESS_B);
+
+        assertThat(fastPairCacheManager.getAllSavedStoredFastPairItem().size())
+                .isEqualTo(1);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/halfsheet/FastPairHalfSheetManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/halfsheet/FastPairHalfSheetManagerTest.java
new file mode 100644
index 0000000..58e4c47
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/halfsheet/FastPairHalfSheetManagerTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.fastpair.halfsheet;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.UserHandle;
+
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+import com.android.server.nearby.fastpair.FastPairController;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import service.proto.Cache;
+
+public class FastPairHalfSheetManagerTest {
+    private static final String BLEADDRESS = "11:22:44:66";
+    private static final String NAME = "device_name";
+    private FastPairHalfSheetManager mFastPairHalfSheetManager;
+    private Cache.ScanFastPairStoreItem mScanFastPairStoreItem;
+    @Mock
+    LocatorContextWrapper mContextWrapper;
+    @Mock
+    ResolveInfo mResolveInfo;
+    @Mock
+    PackageManager mPackageManager;
+    @Mock
+    Locator mLocator;
+    @Mock
+    FastPairController mFastPairController;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+
+        mScanFastPairStoreItem = Cache.ScanFastPairStoreItem.newBuilder()
+                .setAddress(BLEADDRESS)
+                .setDeviceName(NAME)
+                .build();
+    }
+
+    @Test
+    public void verifyFastPairHalfSheetManagerBehavior() {
+        mLocator.overrideBindingForTest(FastPairController.class, mFastPairController);
+        ResolveInfo resolveInfo = new ResolveInfo();
+        List<ResolveInfo> resolveInfoList = new ArrayList<>();
+
+        mPackageManager = mock(PackageManager.class);
+        when(mContextWrapper.getPackageManager()).thenReturn(mPackageManager);
+        resolveInfo.activityInfo = new ActivityInfo();
+        ApplicationInfo applicationInfo = new ApplicationInfo();
+        applicationInfo.sourceDir = "/apex/com.android.tethering";
+        applicationInfo.packageName = "test.package";
+        resolveInfo.activityInfo.applicationInfo = applicationInfo;
+        resolveInfoList.add(resolveInfo);
+        when(mPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(resolveInfoList);
+        when(mPackageManager.canRequestPackageInstalls()).thenReturn(false);
+
+        mFastPairHalfSheetManager =
+                new FastPairHalfSheetManager(mContextWrapper);
+
+        when(mContextWrapper.getLocator()).thenReturn(mLocator);
+
+        ArgumentCaptor<Intent> intentArgumentCaptor = ArgumentCaptor.forClass(Intent.class);
+
+        mFastPairHalfSheetManager.showHalfSheet(mScanFastPairStoreItem);
+
+        verify(mContextWrapper, atLeastOnce())
+                .startActivityAsUser(intentArgumentCaptor.capture(), eq(UserHandle.CURRENT));
+    }
+
+    @Test
+    public void verifyFastPairHalfSheetManagerHalfSheetApkNotValidBehavior() {
+        mLocator.overrideBindingForTest(FastPairController.class, mFastPairController);
+        ResolveInfo resolveInfo = new ResolveInfo();
+        List<ResolveInfo> resolveInfoList = new ArrayList<>();
+
+        mPackageManager = mock(PackageManager.class);
+        when(mContextWrapper.getPackageManager()).thenReturn(mPackageManager);
+        resolveInfo.activityInfo = new ActivityInfo();
+        ApplicationInfo applicationInfo = new ApplicationInfo();
+        // application directory is wrong
+        applicationInfo.sourceDir = "/apex/com.android.nearby";
+        applicationInfo.packageName = "test.package";
+        resolveInfo.activityInfo.applicationInfo = applicationInfo;
+        resolveInfoList.add(resolveInfo);
+        when(mPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(resolveInfoList);
+        when(mPackageManager.canRequestPackageInstalls()).thenReturn(false);
+
+        mFastPairHalfSheetManager =
+                new FastPairHalfSheetManager(mContextWrapper);
+
+        when(mContextWrapper.getLocator()).thenReturn(mLocator);
+
+        ArgumentCaptor<Intent> intentArgumentCaptor = ArgumentCaptor.forClass(Intent.class);
+
+        mFastPairHalfSheetManager.showHalfSheet(mScanFastPairStoreItem);
+
+        verify(mContextWrapper, never())
+                .startActivityAsUser(intentArgumentCaptor.capture(), eq(UserHandle.CURRENT));
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/pairinghandler/PairingProgressHandlerBaseTest.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/pairinghandler/PairingProgressHandlerBaseTest.java
new file mode 100644
index 0000000..2ade5f2
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/pairinghandler/PairingProgressHandlerBaseTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.fastpair.pairinghandler;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+import com.android.server.nearby.fastpair.cache.FastPairCacheManager;
+import com.android.server.nearby.fastpair.footprint.FootprintsDeviceManager;
+import com.android.server.nearby.fastpair.halfsheet.FastPairHalfSheetManager;
+import com.android.server.nearby.fastpair.notification.FastPairNotificationManager;
+import com.android.server.nearby.fastpair.testing.FakeDiscoveryItems;
+
+import com.google.protobuf.ByteString;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.time.Clock;
+
+import service.proto.Cache;
+import service.proto.Rpcs;
+
+public class PairingProgressHandlerBaseTest {
+    @Mock
+    Locator mLocator;
+    @Mock
+    LocatorContextWrapper mContextWrapper;
+    @Mock
+    Clock mClock;
+    @Mock
+    FastPairCacheManager mFastPairCacheManager;
+    @Mock
+    FootprintsDeviceManager mFootprintsDeviceManager;
+    private static final byte[] ACCOUNT_KEY = new byte[]{0x01, 0x02};
+
+    @Before
+    public void setup() {
+
+        MockitoAnnotations.initMocks(this);
+        when(mContextWrapper.getLocator()).thenReturn(mLocator);
+        mLocator.overrideBindingForTest(FastPairCacheManager.class,
+                mFastPairCacheManager);
+        mLocator.overrideBindingForTest(Clock.class, mClock);
+    }
+
+    @Test
+    public void createHandler_halfSheetSubsequentPairing_notificationPairingHandlerCreated() {
+
+        DiscoveryItem discoveryItem = FakeDiscoveryItems.newFastPairDiscoveryItem(mContextWrapper);
+        discoveryItem.setStoredItemForTest(
+                discoveryItem.getStoredItemForTest().toBuilder()
+                        .setAuthenticationPublicKeySecp256R1(ByteString.copyFrom(ACCOUNT_KEY))
+                        .setFastPairInformation(
+                                Cache.FastPairInformation.newBuilder()
+                                        .setDeviceType(Rpcs.DeviceType.HEADPHONES).build())
+                        .build());
+
+        PairingProgressHandlerBase progressHandler =
+                createProgressHandler(ACCOUNT_KEY, discoveryItem, /* isRetroactivePair= */ false);
+
+        assertThat(progressHandler).isInstanceOf(NotificationPairingProgressHandler.class);
+    }
+
+    @Test
+    public void createHandler_halfSheetInitialPairing_halfSheetPairingHandlerCreated() {
+        // No account key
+        DiscoveryItem discoveryItem = FakeDiscoveryItems.newFastPairDiscoveryItem(mContextWrapper);
+        discoveryItem.setStoredItemForTest(
+                discoveryItem.getStoredItemForTest().toBuilder()
+                        .setFastPairInformation(
+                                Cache.FastPairInformation.newBuilder()
+                                        .setDeviceType(Rpcs.DeviceType.HEADPHONES).build())
+                        .build());
+
+        PairingProgressHandlerBase progressHandler =
+                createProgressHandler(null, discoveryItem, /* isRetroactivePair= */ false);
+
+        assertThat(progressHandler).isInstanceOf(HalfSheetPairingProgressHandler.class);
+    }
+
+    private PairingProgressHandlerBase createProgressHandler(
+            @Nullable byte[] accountKey, DiscoveryItem fastPairItem, boolean isRetroactivePair) {
+        FastPairNotificationManager fastPairNotificationManager =
+                new FastPairNotificationManager(mContextWrapper, fastPairItem, true);
+        FastPairHalfSheetManager fastPairHalfSheetManager =
+                new FastPairHalfSheetManager(mContextWrapper);
+        mLocator.overrideBindingForTest(FastPairHalfSheetManager.class, fastPairHalfSheetManager);
+        PairingProgressHandlerBase pairingProgressHandlerBase =
+                PairingProgressHandlerBase.create(
+                        mContextWrapper,
+                        fastPairItem,
+                        fastPairItem.getAppPackageName(),
+                        accountKey,
+                        mFootprintsDeviceManager,
+                        fastPairNotificationManager,
+                        fastPairHalfSheetManager,
+                        isRetroactivePair);
+        return pairingProgressHandlerBase;
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/FakeDiscoveryItems.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/FakeDiscoveryItems.java
new file mode 100644
index 0000000..aa7e6f6
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/FakeDiscoveryItems.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.fastpair.testing;
+
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+import com.android.server.nearby.fastpair.cache.DiscoveryItem;
+
+import service.proto.Cache;
+
+public class FakeDiscoveryItems {
+    public static final String DEFAULT_MAC_ADDRESS = "00:11:22:33:44:55";
+    public static final long DEFAULT_TIMESTAMP = 1000000000L;
+    public static final String DEFAULT_DESCRIPITON = "description";
+    public static final String TRIGGER_ID = "trigger.id";
+    private static final String FAST_PAIR_ID = "id";
+    private static final int RSSI = -80;
+    private static final int TX_POWER = -10;
+    public static DiscoveryItem newFastPairDiscoveryItem(LocatorContextWrapper contextWrapper) {
+        return new DiscoveryItem(contextWrapper, newFastPairDeviceStoredItem());
+    }
+
+    public static Cache.StoredDiscoveryItem newFastPairDeviceStoredItem() {
+        return newFastPairDeviceStoredItem(TRIGGER_ID);
+    }
+
+    public static Cache.StoredDiscoveryItem newFastPairDeviceStoredItem(String triggerId) {
+        Cache.StoredDiscoveryItem.Builder item = Cache.StoredDiscoveryItem.newBuilder();
+        item.setState(Cache.StoredDiscoveryItem.State.STATE_ENABLED);
+        item.setId(FAST_PAIR_ID);
+        item.setDescription(DEFAULT_DESCRIPITON);
+        item.setType(Cache.NearbyType.NEARBY_DEVICE);
+        item.setTriggerId(triggerId);
+        item.setMacAddress(DEFAULT_MAC_ADDRESS);
+        item.setFirstObservationTimestampMillis(DEFAULT_TIMESTAMP);
+        item.setLastObservationTimestampMillis(DEFAULT_TIMESTAMP);
+        item.setRssi(RSSI);
+        item.setTxPower(TX_POWER);
+        return item.build();
+    }
+
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/MockingLocator.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/MockingLocator.java
new file mode 100644
index 0000000..b261b26
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/MockingLocator.java
@@ -0,0 +1,53 @@
+/*
+ * 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 src.com.android.server.nearby.fastpair.testing;
+
+import android.content.Context;
+
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.LocatorContextWrapper;
+
+/** A locator for tests that, by default, installs mocks for everything that's requested of it. */
+public class MockingLocator extends Locator {
+    private final LocatorContextWrapper mLocatorContextWrapper;
+
+    /**
+     * Creates a MockingLocator with the explicit bindings already configured on the given locator.
+     */
+    public static MockingLocator withBindings(Context context, Locator locator) {
+        Locator mockingLocator = new Locator(context);
+        mockingLocator.bind(new MockingModule());
+        locator.attachParent(mockingLocator);
+        return new MockingLocator(context, locator);
+    }
+
+    /** Creates a MockingLocator with no explicit bindings. */
+    public static MockingLocator withMocksOnly(Context context) {
+        return withBindings(context, new Locator(context));
+    }
+
+    @SuppressWarnings("nullness") // due to passing in this before initialized.
+    private MockingLocator(Context context, Locator locator) {
+        super(context, locator);
+        this.mLocatorContextWrapper = new LocatorContextWrapper(context, this);
+    }
+
+    /** Returns a LocatorContextWrapper with this Locator attached. */
+    public LocatorContextWrapper getContextForTest() {
+        return mLocatorContextWrapper;
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/MockingModule.java b/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/MockingModule.java
new file mode 100644
index 0000000..7938c55
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/fastpair/testing/MockingModule.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 src.com.android.server.nearby.fastpair.testing;
+
+import android.content.Context;
+
+import com.android.server.nearby.common.locator.Locator;
+import com.android.server.nearby.common.locator.Module;
+
+
+import org.mockito.Mockito;
+
+/** Module for tests that just provides mocks for anything that's requested of it. */
+public class MockingModule extends Module {
+
+    @Override
+    public void configure(Context context, Class<?> type, Locator locator) {
+        configureMock(type, locator);
+    }
+
+    private <T> void configureMock(Class<T> type, Locator locator) {
+        T mock = Mockito.mock(type);
+        locator.bind(type, mock);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/metrics/NearbyMetricsTest.java b/nearby/tests/unit/src/com/android/server/nearby/metrics/NearbyMetricsTest.java
new file mode 100644
index 0000000..c4d1ee2
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/metrics/NearbyMetricsTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.metrics;
+
+import static android.nearby.ScanRequest.SCAN_MODE_BALANCED;
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_SHARE;
+
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PublicCredential;
+import android.nearby.ScanRequest;
+import android.os.WorkSource;
+
+import com.android.dx.mockito.inline.extended.ExtendedMockito;
+import com.android.server.nearby.proto.NearbyStatsLog;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockitoAnnotations;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+
+public class NearbyMetricsTest {
+    private static final int SESSION_ID = 11111;
+    private static final int WORK_SOURCE_UID = 2222;
+
+    private static final String DEVICE_NAME = "testDevice";
+    private static final int SCAN_MEDIUM = 1;
+    private static final int RSSI = -60;
+    private static final String FAST_PAIR_MODEL_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 PublicCredential PUBLIC_CREDENTIAL =
+            new PublicCredential.Builder(
+                            new byte[] {1},
+                            new byte[] {2},
+                            new byte[] {3},
+                            new byte[] {4},
+                            new byte[] {5})
+                    .build();
+
+    private final WorkSource mWorkSource = new WorkSource(WORK_SOURCE_UID);
+    private final WorkSource mEmptyWorkSource = new WorkSource();
+
+    private final ScanRequest.Builder mScanRequestBuilder =
+            new ScanRequest.Builder()
+                    .setScanMode(SCAN_MODE_BALANCED)
+                    .setScanType(SCAN_TYPE_NEARBY_SHARE);
+    private final ScanRequest mScanRequest = mScanRequestBuilder.setWorkSource(mWorkSource).build();
+    private final ScanRequest mScanRequestWithEmptyWorkSource =
+            mScanRequestBuilder.setWorkSource(mEmptyWorkSource).build();
+
+    private final NearbyDeviceParcelable mNearbyDevice =
+            new NearbyDeviceParcelable.Builder()
+                    .setName(DEVICE_NAME)
+                    .setMedium(SCAN_MEDIUM)
+                    .setTxPower(1)
+                    .setRssi(RSSI)
+                    .setAction(1)
+                    .setPublicCredential(PUBLIC_CREDENTIAL)
+                    .setFastPairModelId(FAST_PAIR_MODEL_ID)
+                    .setBluetoothAddress(BLUETOOTH_ADDRESS)
+                    .setData(SCAN_DATA)
+                    .build();
+
+    private MockitoSession mSession;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mSession =
+                ExtendedMockito.mockitoSession()
+                        .strictness(Strictness.LENIENT)
+                        .mockStatic(NearbyStatsLog.class)
+                        .startMocking();
+    }
+
+    @After
+    public void tearDown() {
+        mSession.finishMocking();
+    }
+
+    @Test
+    public void testLogScanStart() {
+        NearbyMetrics.logScanStarted(SESSION_ID, mScanRequest);
+        ExtendedMockito.verify(() -> NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                WORK_SOURCE_UID,
+                SESSION_ID,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_STARTED,
+                SCAN_TYPE_NEARBY_SHARE,
+                0,
+                0,
+                "",
+                ""));
+    }
+
+    @Test
+    public void testLogScanStart_emptyWorkSource() {
+        NearbyMetrics.logScanStarted(SESSION_ID, mScanRequestWithEmptyWorkSource);
+        ExtendedMockito.verify(() -> NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                -1,
+                SESSION_ID,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_STARTED,
+                SCAN_TYPE_NEARBY_SHARE,
+                0,
+                0,
+                "",
+                ""));
+    }
+
+    @Test
+    public void testLogScanStopped() {
+        NearbyMetrics.logScanStopped(SESSION_ID, mScanRequest);
+        ExtendedMockito.verify(() -> NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                WORK_SOURCE_UID,
+                SESSION_ID,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_STOPPED,
+                SCAN_TYPE_NEARBY_SHARE,
+                0,
+                0,
+                "",
+                ""));
+    }
+
+    @Test
+    public void testLogScanStopped_emptyWorkSource() {
+        NearbyMetrics.logScanStopped(SESSION_ID, mScanRequestWithEmptyWorkSource);
+        ExtendedMockito.verify(() -> NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                -1,
+                SESSION_ID,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_STOPPED,
+                SCAN_TYPE_NEARBY_SHARE,
+                0,
+                0,
+                "",
+                ""));
+    }
+
+    @Test
+    public void testLogScanDeviceDiscovered() {
+        NearbyMetrics.logScanDeviceDiscovered(SESSION_ID, mScanRequest, mNearbyDevice);
+        ExtendedMockito.verify(() -> NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                WORK_SOURCE_UID,
+                SESSION_ID,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_DISCOVERED,
+                SCAN_TYPE_NEARBY_SHARE,
+                SCAN_MEDIUM,
+                RSSI,
+                FAST_PAIR_MODEL_ID,
+                ""));
+    }
+
+    @Test
+    public void testLogScanDeviceDiscovered_emptyWorkSource() {
+        NearbyMetrics.logScanDeviceDiscovered(
+                SESSION_ID, mScanRequestWithEmptyWorkSource, mNearbyDevice);
+        ExtendedMockito.verify(() -> NearbyStatsLog.write(
+                NearbyStatsLog.NEARBY_DEVICE_SCAN_STATE_CHANGED,
+                -1,
+                SESSION_ID,
+                NearbyStatsLog
+                        .NEARBY_DEVICE_SCAN_STATE_CHANGED__SCAN_STATE__NEARBY_SCAN_STATE_DISCOVERED,
+                SCAN_TYPE_NEARBY_SHARE,
+                SCAN_MEDIUM,
+                RSSI,
+                FAST_PAIR_MODEL_ID,
+                ""));
+    }
+}
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
new file mode 100644
index 0000000..5e0ccbe
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/FastAdvertisementTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.PresenceBroadcastRequest;
+import android.nearby.PresenceCredential;
+import android.nearby.PrivateCredential;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collections;
+
+/**
+ * Unit test for {@link FastAdvertisement}.
+ */
+public class FastAdvertisementTest {
+
+    private static final int IDENTITY_TYPE = PresenceCredential.IDENTITY_TYPE_PRIVATE;
+    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};
+    private static final byte TX_POWER = 4;
+    private static final int PRESENCE_ACTION = 123;
+    private static final byte[] SECRET_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] AUTHENTICITY_KEY = new byte[]{12, 13, 14};
+    private static final byte[] EXPECTED_ADV_BYTES =
+            new byte[]{2, 2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 123};
+    private static final String DEVICE_NAME = "test_device";
+
+    private PresenceBroadcastRequest.Builder mBuilder;
+    private PrivateCredential mCredential;
+
+    @Before
+    public void setUp() {
+        mCredential =
+                new PrivateCredential.Builder(SECRET_ID, AUTHENTICITY_KEY, IDENTITY, DEVICE_NAME)
+                        .setIdentityType(PresenceCredential.IDENTITY_TYPE_PRIVATE)
+                        .build();
+        mBuilder =
+                new PresenceBroadcastRequest.Builder(Collections.singletonList(MEDIUM_TYPE_BLE),
+                        SALT, mCredential)
+                        .setTxPower(TX_POWER)
+                        .setVersion(BroadcastRequest.PRESENCE_VERSION_V0)
+                        .addAction(PRESENCE_ACTION);
+    }
+
+    @Test
+    public void testFastAdvertisementCreateFromRequest() {
+        FastAdvertisement originalAdvertisement = FastAdvertisement.createFromRequest(
+                mBuilder.build());
+
+        assertThat(originalAdvertisement.getActions()).containsExactly(PRESENCE_ACTION);
+        assertThat(originalAdvertisement.getIdentity()).isEqualTo(IDENTITY);
+        assertThat(originalAdvertisement.getIdentityType()).isEqualTo(IDENTITY_TYPE);
+        assertThat(originalAdvertisement.getLtvFieldCount()).isEqualTo(4);
+        assertThat(originalAdvertisement.getLength()).isEqualTo(19);
+        assertThat(originalAdvertisement.getVersion()).isEqualTo(
+                BroadcastRequest.PRESENCE_VERSION_V0);
+        assertThat(originalAdvertisement.getSalt()).isEqualTo(SALT);
+    }
+
+    @Test
+    public void testFastAdvertisementSerialization() {
+        FastAdvertisement originalAdvertisement = FastAdvertisement.createFromRequest(
+                mBuilder.build());
+        byte[] bytes = originalAdvertisement.toBytes();
+
+        assertThat(bytes).hasLength(originalAdvertisement.getLength());
+        assertThat(bytes).isEqualTo(EXPECTED_ADV_BYTES);
+    }
+}
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
new file mode 100644
index 0000000..39cab94
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceDiscoveryResultTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.PresenceCredential;
+import android.nearby.PresenceDevice;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+
+/**
+ * Unit tests for {@link PresenceDiscoveryResult}.
+ */
+public class PresenceDiscoveryResultTest {
+    private static final int PRESENCE_ACTION = 123;
+    private static final int TX_POWER = -1;
+    private static final int RSSI = -41;
+    private static final byte[] SALT = new byte[]{12, 34};
+    private static final byte[] SECRET_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] AUTHENTICITY_KEY = new byte[]{12, 13, 14};
+    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 PresenceDiscoveryResult.Builder mBuilder;
+    private PublicCredential mCredential;
+
+    @Before
+    public void setUp() {
+        mCredential =
+                new PublicCredential.Builder(SECRET_ID, AUTHENTICITY_KEY, PUBLIC_KEY,
+                        ENCRYPTED_METADATA, METADATA_ENCRYPTION_KEY_TAG)
+                        .setIdentityType(PresenceCredential.IDENTITY_TYPE_PRIVATE)
+                        .build();
+        mBuilder = new PresenceDiscoveryResult.Builder()
+                .setPublicCredential(mCredential)
+                .setSalt(SALT)
+                .setTxPower(TX_POWER)
+                .setRssi(RSSI)
+                .addPresenceAction(PRESENCE_ACTION);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testToDevice() {
+        PresenceDiscoveryResult discoveryResult = mBuilder.build();
+        PresenceDevice presenceDevice = discoveryResult.toPresenceDevice();
+
+        assertThat(presenceDevice.getRssi()).isEqualTo(RSSI);
+        assertThat(Arrays.equals(presenceDevice.getSalt(), SALT)).isTrue();
+        assertThat(Arrays.equals(presenceDevice.getSecretId(), SECRET_ID)).isTrue();
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testMatches() {
+        PresenceScanFilter scanFilter = new PresenceScanFilter.Builder()
+                .setMaxPathLoss(80)
+                .addPresenceAction(PRESENCE_ACTION)
+                .addCredential(mCredential)
+                .build();
+
+        PresenceDiscoveryResult discoveryResult = mBuilder.build();
+        assertThat(discoveryResult.matches(scanFilter)).isTrue();
+    }
+
+}
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..d32e325
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceManagerTest.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.presence;
+
+
+
+
+import androidx.test.filters.SdkSuppress;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockitoAnnotations;
+
+public class PresenceManagerTest {
+    private PresenceManager mPresenceManager;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testInit() {}
+}
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
new file mode 100644
index 0000000..f485e18
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.provider;
+
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.le.AdvertiseSettings;
+import android.content.Context;
+import android.nearby.BroadcastCallback;
+
+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;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+/**
+ * Unit test for {@link BleBroadcastProvider}.
+ */
+public class BleBroadcastProviderTest {
+    @Rule
+    public final MockitoRule mocks = MockitoJUnit.rule();
+
+    @Mock
+    private BleBroadcastProvider.BroadcastListener mBroadcastListener;
+    private BleBroadcastProvider mBleBroadcastProvider;
+
+    @Before
+    public void setUp() {
+        mBleBroadcastProvider = new BleBroadcastProvider(new TestInjector(),
+                MoreExecutors.directExecutor());
+    }
+
+    @Test
+    public void testOnStatus_success() {
+        byte[] advertiseBytes = new byte[]{1, 2, 3, 4};
+        mBleBroadcastProvider.start(advertiseBytes, mBroadcastListener);
+        verify(mBroadcastListener).onStatusChanged(eq(BroadcastCallback.STATUS_FAILURE));
+
+        AdvertiseSettings settings = new AdvertiseSettings.Builder().build();
+        mBleBroadcastProvider.onStartSuccess(settings);
+        verify(mBroadcastListener).onStatusChanged(eq(BroadcastCallback.STATUS_OK));
+    }
+
+    @Test
+    public void testOnStatus_failure() {
+        byte[] advertiseBytes = new byte[]{1, 2, 3, 4};
+        mBleBroadcastProvider.start(advertiseBytes, mBroadcastListener);
+
+        mBleBroadcastProvider.onStartFailure(BroadcastCallback.STATUS_FAILURE);
+        verify(mBroadcastListener, times(2)).onStatusChanged(eq(BroadcastCallback.STATUS_FAILURE));
+    }
+
+    private static class TestInjector implements Injector {
+
+        @Override
+        public BluetoothAdapter getBluetoothAdapter() {
+            Context context = ApplicationProvider.getApplicationContext();
+            BluetoothManager bluetoothManager = context.getSystemService(BluetoothManager.class);
+            return bluetoothManager.getAdapter();
+        }
+
+        @Override
+        public ContextHubManagerAdapter getContextHubManagerAdapter() {
+            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
new file mode 100644
index 0000000..8e97443
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.provider;
+
+import static android.bluetooth.le.ScanSettings.CALLBACK_TYPE_ALL_MATCHES;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.server.nearby.injector.ContextHubManagerAdapter;
+import com.android.server.nearby.injector.Injector;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public final class BleDiscoveryProviderTest {
+
+    private BluetoothAdapter mBluetoothAdapter;
+    private BleDiscoveryProvider mBleDiscoveryProvider;
+    @Mock
+    private AbstractDiscoveryProvider.Listener mListener;
+
+    @Before
+    public void setup() {
+        initMocks(this);
+        Context context = InstrumentationRegistry.getInstrumentation().getContext();
+        Injector injector = new TestInjector();
+
+        mBluetoothAdapter = context.getSystemService(BluetoothManager.class).getAdapter();
+        mBleDiscoveryProvider = new BleDiscoveryProvider(context, injector);
+    }
+
+    @Test
+    public void test_callback() throws InterruptedException {
+        mBleDiscoveryProvider.getController().setListener(mListener);
+        mBleDiscoveryProvider.onStart();
+        mBleDiscoveryProvider.getScanCallback()
+                .onScanResult(CALLBACK_TYPE_ALL_MATCHES, createScanResult());
+
+        // Wait for callback to be invoked
+        Thread.sleep(500);
+        verify(mListener, times(1)).onNearbyDeviceDiscovered(any());
+    }
+
+    @Test
+    public void test_stopScan() {
+        mBleDiscoveryProvider.onStart();
+        mBleDiscoveryProvider.onStop();
+    }
+
+    private class TestInjector implements Injector {
+        @Override
+        public BluetoothAdapter getBluetoothAdapter() {
+            return mBluetoothAdapter;
+        }
+
+        @Override
+        public ContextHubManagerAdapter getContextHubManagerAdapter() {
+            return null;
+        }
+    }
+
+    private ScanResult createScanResult() {
+        BluetoothDevice bluetoothDevice = mBluetoothAdapter
+                .getRemoteDevice("11:22:33:44:55:66");
+        byte[] scanRecord = new byte[] {2, 1, 6, 6, 22, 44, -2, 113, -116, 23, 2, 10, -11, 0, 0, 0,
+                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+        return new ScanResult(
+                bluetoothDevice,
+                /* eventType= */ 0,
+                /* primaryPhy= */ 0,
+                /* secondaryPhy= */ 0,
+                /* advertisingSid= */ 0,
+                -31,
+                -50,
+                /* periodicAdvertisingInterval= */ 0,
+                parseScanRecord(scanRecord),
+                1645579363003L);
+    }
+
+    private static ScanRecord parseScanRecord(byte[] bytes) {
+        Class<?> scanRecordClass = ScanRecord.class;
+        try {
+            Method method = scanRecordClass
+                    .getDeclaredMethod("parseFromBytes", byte[].class);
+            return (ScanRecord) method.invoke(null, bytes);
+        } catch (NoSuchMethodException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException e) {
+            return null;
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/BroadcastProviderManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/BroadcastProviderManagerTest.java
new file mode 100644
index 0000000..d45d570
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/BroadcastProviderManagerTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.provider;
+
+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 org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.verify;
+
+import android.app.UiAutomation;
+import android.content.Context;
+import android.nearby.BroadcastCallback;
+import android.nearby.BroadcastRequest;
+import android.nearby.IBroadcastListener;
+import android.nearby.PresenceBroadcastRequest;
+import android.nearby.PresenceCredential;
+import android.nearby.PrivateCredential;
+import android.provider.DeviceConfig;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.util.Collections;
+
+/**
+ * Unit test for {@link BroadcastProviderManager}.
+ */
+public class BroadcastProviderManagerTest {
+    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};
+    private static final byte TX_POWER = 4;
+    private static final int PRESENCE_ACTION = 123;
+    private static final byte[] SECRET_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] AUTHENTICITY_KEY = new byte[]{12, 13, 14};
+    private static final String DEVICE_NAME = "test_device";
+
+    @Rule
+    public final MockitoRule mocks = MockitoJUnit.rule();
+
+    @Mock
+    IBroadcastListener mBroadcastListener;
+    @Mock
+    BleBroadcastProvider mBleBroadcastProvider;
+    private Context mContext;
+    private BroadcastProviderManager mBroadcastProviderManager;
+    private BroadcastRequest mBroadcastRequest;
+    private UiAutomation mUiAutomation =
+            InstrumentationRegistry.getInstrumentation().getUiAutomation();
+
+    @Before
+    public void setUp() {
+        mUiAutomation.adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+        DeviceConfig.setProperty(NAMESPACE_TETHERING, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY,
+                "true", false);
+
+        mContext = ApplicationProvider.getApplicationContext();
+        mBroadcastProviderManager = new BroadcastProviderManager(MoreExecutors.directExecutor(),
+                mBleBroadcastProvider);
+
+        PrivateCredential privateCredential =
+                new PrivateCredential.Builder(SECRET_ID, AUTHENTICITY_KEY, IDENTITY, DEVICE_NAME)
+                        .setIdentityType(PresenceCredential.IDENTITY_TYPE_PRIVATE)
+                        .build();
+        mBroadcastRequest =
+                new PresenceBroadcastRequest.Builder(Collections.singletonList(MEDIUM_TYPE_BLE),
+                        SALT, privateCredential)
+                        .setTxPower(TX_POWER)
+                        .setVersion(BroadcastRequest.PRESENCE_VERSION_V0)
+                        .addAction(PRESENCE_ACTION).build();
+    }
+
+    @Test
+    public void testStartAdvertising() {
+        mBroadcastProviderManager.startBroadcast(mBroadcastRequest, mBroadcastListener);
+        verify(mBleBroadcastProvider).start(any(byte[].class), any(
+                BleBroadcastProvider.BroadcastListener.class));
+    }
+
+    @Test
+    public void testStartAdvertising_featureDisabled() throws Exception {
+        DeviceConfig.setProperty(NAMESPACE_TETHERING, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY,
+                "false", false);
+        mBroadcastProviderManager = new BroadcastProviderManager(MoreExecutors.directExecutor(),
+                mBleBroadcastProvider);
+        mBroadcastProviderManager.startBroadcast(mBroadcastRequest, mBroadcastListener);
+        verify(mBroadcastListener).onStatusChanged(eq(BroadcastCallback.STATUS_FAILURE));
+    }
+
+    @Test
+    public void testOnStatusChanged() throws Exception {
+        mBroadcastProviderManager.startBroadcast(mBroadcastRequest, mBroadcastListener);
+        mBroadcastProviderManager.onStatusChanged(BroadcastCallback.STATUS_OK);
+        verify(mBroadcastListener).onStatusChanged(eq(BroadcastCallback.STATUS_OK));
+    }
+}
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
new file mode 100644
index 0000000..1b29b52
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.provider;
+
+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.hardware.location.ContextHubClient;
+import android.hardware.location.ContextHubInfo;
+import android.hardware.location.ContextHubTransaction;
+import android.hardware.location.NanoAppMessage;
+import android.hardware.location.NanoAppState;
+
+import com.android.server.nearby.injector.ContextHubManagerAdapter;
+import com.android.server.nearby.injector.Injector;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+public class ChreCommunicationTest {
+    @Mock Injector mInjector;
+    @Mock ContextHubManagerAdapter mManager;
+    @Mock ContextHubTransaction<List<NanoAppState>> mTransaction;
+    @Mock ContextHubTransaction.Response<List<NanoAppState>> mTransactionResponse;
+    @Mock ContextHubClient mClient;
+    @Mock ChreCommunication.ContextHubCommsCallback mChreCallback;
+
+    @Captor
+    ArgumentCaptor<ChreCommunication.OnQueryCompleteListener> mOnQueryCompleteListenerCaptor;
+
+    private ChreCommunication mChreCommunication;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        when(mInjector.getContextHubManagerAdapter()).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(mTransactionResponse.getResult()).thenReturn(ContextHubTransaction.RESULT_SUCCESS);
+        when(mTransactionResponse.getContents())
+                .thenReturn(
+                        Collections.singletonList(
+                                new NanoAppState(ChreDiscoveryProvider.NANOAPP_ID, 1, true)));
+
+        mChreCommunication = new ChreCommunication(mInjector, new InlineExecutor());
+        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.stop();
+        verify(mClient).close();
+    }
+
+    @Test
+    public void testSendMessageToNanApp() {
+        NanoAppMessage message =
+                NanoAppMessage.createMessageToNanoApp(
+                        ChreDiscoveryProvider.NANOAPP_ID,
+                        ChreDiscoveryProvider.NANOAPP_MESSAGE_TYPE_FILTER,
+                        new byte[] {1, 2, 3});
+        mChreCommunication.sendMessageToNanoApp(message);
+        verify(mClient).sendMessageToNanoApp(eq(message));
+    }
+
+    @Test
+    public void testOnMessageFromNanoApp() {
+        NanoAppMessage message =
+                NanoAppMessage.createMessageToNanoApp(
+                        ChreDiscoveryProvider.NANOAPP_ID,
+                        ChreDiscoveryProvider.NANOAPP_MESSAGE_TYPE_FILTER_RESULT,
+                        new byte[] {1, 2, 3});
+        mChreCommunication.onMessageFromNanoApp(mClient, message);
+        verify(mChreCallback).onMessageFromNanoApp(eq(message));
+    }
+
+    @Test
+    public void testOnHubReset() {
+        mChreCommunication.onHubReset(mClient);
+        verify(mChreCallback).onHubReset();
+    }
+
+    @Test
+    public void testOnNanoAppLoaded() {
+        mChreCommunication.onNanoAppLoaded(mClient, ChreDiscoveryProvider.NANOAPP_ID);
+        verify(mChreCallback).onNanoAppRestart(eq(ChreDiscoveryProvider.NANOAPP_ID));
+    }
+
+    private static class InlineExecutor implements Executor {
+        @Override
+        public void execute(Runnable command) {
+            command.run();
+        }
+    }
+}
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
new file mode 100644
index 0000000..7c0dd92
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.provider;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.hardware.location.NanoAppMessage;
+import android.nearby.ScanFilter;
+
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.google.protobuf.ByteString;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+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;
+
+public class ChreDiscoveryProviderTest {
+    @Mock AbstractDiscoveryProvider.Listener mListener;
+    @Mock ChreCommunication mChreCommunication;
+
+    @Captor ArgumentCaptor<ChreCommunication.ContextHubCommsCallback> mChreCallbackCaptor;
+
+    private ChreDiscoveryProvider mChreDiscoveryProvider;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        Context context = InstrumentationRegistry.getInstrumentation().getContext();
+        mChreDiscoveryProvider =
+                new ChreDiscoveryProvider(context, mChreCommunication, new InLineExecutor());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testOnStart() {
+        List<ScanFilter> scanFilters = new ArrayList<>();
+        mChreDiscoveryProvider.getController().setProviderScanFilters(scanFilters);
+        mChreDiscoveryProvider.onStart();
+        verify(mChreCommunication).start(mChreCallbackCaptor.capture(), any());
+        mChreCallbackCaptor.getValue().started(true);
+        verify(mChreCommunication).sendMessageToNanoApp(any());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testOnNearbyDeviceDiscovered() {
+        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)
+                        .setPublicCredential(credential)
+                        .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.onStart();
+        verify(mChreCommunication).start(mChreCallbackCaptor.capture(), any());
+        mChreCallbackCaptor.getValue().onMessageFromNanoApp(chre_message);
+        verify(mListener).onNearbyDeviceDiscovered(any());
+    }
+
+    private static class InLineExecutor implements Executor {
+        @Override
+        public void execute(Runnable command) {
+            command.run();
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/UtilsTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/UtilsTest.java
new file mode 100644
index 0000000..205d5c2
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/UtilsTest.java
@@ -0,0 +1,733 @@
+/*
+ * 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.provider;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.accounts.Account;
+import android.nearby.aidl.FastPairAccountKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairAntispoofKeyDeviceMetadataParcel;
+import android.nearby.aidl.FastPairDeviceMetadataParcel;
+import android.nearby.aidl.FastPairDiscoveryItemParcel;
+import android.nearby.aidl.FastPairEligibleAccountParcel;
+
+import androidx.test.filters.SdkSuppress;
+
+import com.android.server.nearby.fastpair.footprint.FastPairUploadInfo;
+
+import com.google.protobuf.ByteString;
+
+import org.junit.Test;
+
+import java.util.List;
+
+import service.proto.Cache;
+import service.proto.Data;
+import service.proto.FastPairString.FastPairStrings;
+import service.proto.Rpcs;
+
+public class UtilsTest {
+
+    private static final String ASSISTANT_SETUP_HALFSHEET = "ASSISTANT_SETUP_HALFSHEET";
+    private static final String ASSISTANT_SETUP_NOTIFICATION = "ASSISTANT_SETUP_NOTIFICATION";
+    private static final int BLE_TX_POWER = 5;
+    private static final String CONFIRM_PIN_DESCRIPTION = "CONFIRM_PIN_DESCRIPTION";
+    private static final String CONFIRM_PIN_TITLE = "CONFIRM_PIN_TITLE";
+    private static final String CONNECT_SUCCESS_COMPANION_APP_INSTALLED =
+            "CONNECT_SUCCESS_COMPANION_APP_INSTALLED";
+    private static final String CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED =
+            "CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED";
+    private static final int DEVICE_TYPE = 1;
+    private static final String DOWNLOAD_COMPANION_APP_DESCRIPTION =
+            "DOWNLOAD_COMPANION_APP_DESCRIPTION";
+    private static final Account ELIGIBLE_ACCOUNT_1 = new Account("abc@google.com", "type1");
+    private static final String FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION =
+            "FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION";
+    private static final String FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION =
+            "FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION";
+    private static final byte[] IMAGE = new byte[]{7, 9};
+    private static final String IMAGE_URL = "IMAGE_URL";
+    private static final String INITIAL_NOTIFICATION_DESCRIPTION =
+            "INITIAL_NOTIFICATION_DESCRIPTION";
+    private static final String INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT =
+            "INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT";
+    private static final String INITIAL_PAIRING_DESCRIPTION = "INITIAL_PAIRING_DESCRIPTION";
+    private static final String INTENT_URI = "INTENT_URI";
+    private static final String LOCALE = "LOCALE";
+    private static final String OPEN_COMPANION_APP_DESCRIPTION = "OPEN_COMPANION_APP_DESCRIPTION";
+    private static final String RETRO_ACTIVE_PAIRING_DESCRIPTION =
+            "RETRO_ACTIVE_PAIRING_DESCRIPTION";
+    private static final String SUBSEQUENT_PAIRING_DESCRIPTION = "SUBSEQUENT_PAIRING_DESCRIPTION";
+    private static final String SYNC_CONTACT_DESCRPTION = "SYNC_CONTACT_DESCRPTION";
+    private static final String SYNC_CONTACTS_TITLE = "SYNC_CONTACTS_TITLE";
+    private static final String SYNC_SMS_DESCRIPTION = "SYNC_SMS_DESCRIPTION";
+    private static final String SYNC_SMS_TITLE = "SYNC_SMS_TITLE";
+    private static final float TRIGGER_DISTANCE = 111;
+    private static final String TRUE_WIRELESS_IMAGE_URL_CASE = "TRUE_WIRELESS_IMAGE_URL_CASE";
+    private static final String TRUE_WIRELESS_IMAGE_URL_LEFT_BUD =
+            "TRUE_WIRELESS_IMAGE_URL_LEFT_BUD";
+    private static final String TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD =
+            "TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD";
+    private static final String UNABLE_TO_CONNECT_DESCRIPTION = "UNABLE_TO_CONNECT_DESCRIPTION";
+    private static final String UNABLE_TO_CONNECT_TITLE = "UNABLE_TO_CONNECT_TITLE";
+    private static final String UPDATE_COMPANION_APP_DESCRIPTION =
+            "UPDATE_COMPANION_APP_DESCRIPTION";
+    private static final String WAIT_LAUNCH_COMPANION_APP_DESCRIPTION =
+            "WAIT_LAUNCH_COMPANION_APP_DESCRIPTION";
+    private static final byte[] ACCOUNT_KEY = new byte[]{3};
+    private static final byte[] SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS = new byte[]{2, 8};
+    private static final byte[] ANTI_SPOOFING_KEY = new byte[]{4, 5, 6};
+    private static final String ACTION_URL = "ACTION_URL";
+    private static final int ACTION_URL_TYPE = 1;
+    private static final String APP_NAME = "APP_NAME";
+    private static final int ATTACHMENT_TYPE = 1;
+    private static final byte[] AUTHENTICATION_PUBLIC_KEY_SEC_P256R1 = new byte[]{5, 7};
+    private static final byte[] BLE_RECORD_BYTES = new byte[]{2, 4};
+    private static final int DEBUG_CATEGORY = 1;
+    private static final String DEBUG_MESSAGE = "DEBUG_MESSAGE";
+    private static final String DESCRIPTION = "DESCRIPTION";
+    private static final String DEVICE_NAME = "DEVICE_NAME";
+    private static final String DISPLAY_URL = "DISPLAY_URL";
+    private static final String ENTITY_ID = "ENTITY_ID";
+    private static final String FEATURE_GRAPHIC_URL = "FEATURE_GRAPHIC_URL";
+    private static final long FIRST_OBSERVATION_TIMESTAMP_MILLIS = 8393L;
+    private static final String GROUP_ID = "GROUP_ID";
+    private static final String ICON_FIFE_URL = "ICON_FIFE_URL";
+    private static final byte[] ICON_PNG = new byte[]{2, 5};
+    private static final String ID = "ID";
+    private static final long LAST_OBSERVATION_TIMESTAMP_MILLIS = 934234L;
+    private static final int LAST_USER_EXPERIENCE = 1;
+    private static final long LOST_MILLIS = 393284L;
+    private static final String MAC_ADDRESS = "MAC_ADDRESS";
+    private static final String NAME = "NAME";
+    private static final String PACKAGE_NAME = "PACKAGE_NAME";
+    private static final long PENDING_APP_INSTALL_TIMESTAMP_MILLIS = 832393L;
+    private static final int RSSI = 9;
+    private static final int STATE = 1;
+    private static final String TITLE = "TITLE";
+    private static final String TRIGGER_ID = "TRIGGER_ID";
+    private static final int TX_POWER = 63;
+    private static final int TYPE = 1;
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathConvertToFastPairDevicesWithAccountKey() {
+        FastPairAccountKeyDeviceMetadataParcel[] array = {
+                genHappyPathFastPairAccountkeyDeviceMetadataParcel()};
+
+        List<Data.FastPairDeviceWithAccountKey> deviceWithAccountKey =
+                Utils.convertToFastPairDevicesWithAccountKey(array);
+        assertThat(deviceWithAccountKey.size()).isEqualTo(1);
+        assertThat(deviceWithAccountKey.get(0)).isEqualTo(
+                genHappyPathFastPairDeviceWithAccountKey());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToFastPairDevicesWithAccountKeyWithNullArray() {
+        FastPairAccountKeyDeviceMetadataParcel[] array = null;
+        assertThat(Utils.convertToFastPairDevicesWithAccountKey(array).size()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToFastPairDevicesWithAccountKeyWithNullElement() {
+        FastPairAccountKeyDeviceMetadataParcel[] array = {null};
+        assertThat(Utils.convertToFastPairDevicesWithAccountKey(array).size()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToFastPairDevicesWithAccountKeyWithEmptyElementNoCrash() {
+        FastPairAccountKeyDeviceMetadataParcel[] array = {
+                genEmptyFastPairAccountkeyDeviceMetadataParcel()};
+
+        List<Data.FastPairDeviceWithAccountKey> deviceWithAccountKey =
+                Utils.convertToFastPairDevicesWithAccountKey(array);
+        assertThat(deviceWithAccountKey.size()).isEqualTo(1);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToFastPairDevicesWithAccountKeyWithEmptyMetadataDiscoveryNoCrash() {
+        FastPairAccountKeyDeviceMetadataParcel[] array = {
+                genFastPairAccountkeyDeviceMetadataParcelWithEmptyMetadataDiscoveryItem()};
+
+        List<Data.FastPairDeviceWithAccountKey> deviceWithAccountKey =
+                Utils.convertToFastPairDevicesWithAccountKey(array);
+        assertThat(deviceWithAccountKey.size()).isEqualTo(1);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToFastPairDevicesWithAccountKeyWithMixedArrayElements() {
+        FastPairAccountKeyDeviceMetadataParcel[] array = {
+                null,
+                genHappyPathFastPairAccountkeyDeviceMetadataParcel(),
+                genEmptyFastPairAccountkeyDeviceMetadataParcel(),
+                genFastPairAccountkeyDeviceMetadataParcelWithEmptyMetadataDiscoveryItem()};
+
+        assertThat(Utils.convertToFastPairDevicesWithAccountKey(array).size()).isEqualTo(3);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathConvertToAccountList() {
+        FastPairEligibleAccountParcel[] array = {genHappyPathFastPairEligibleAccountParcel()};
+
+        List<Account> accountList = Utils.convertToAccountList(array);
+        assertThat(accountList.size()).isEqualTo(1);
+        assertThat(accountList.get(0)).isEqualTo(ELIGIBLE_ACCOUNT_1);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToAccountListNullArray() {
+        FastPairEligibleAccountParcel[] array = null;
+
+        List<Account> accountList = Utils.convertToAccountList(array);
+        assertThat(accountList.size()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToAccountListWithNullElement() {
+        FastPairEligibleAccountParcel[] array = {null};
+
+        List<Account> accountList = Utils.convertToAccountList(array);
+        assertThat(accountList.size()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToAccountListWithEmptyElementNotCrash() {
+        FastPairEligibleAccountParcel[] array =
+                {genEmptyFastPairEligibleAccountParcel()};
+
+        List<Account> accountList = Utils.convertToAccountList(array);
+        assertThat(accountList.size()).isEqualTo(0);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToAccountListWithMixedArrayElements() {
+        FastPairEligibleAccountParcel[] array = {
+                genHappyPathFastPairEligibleAccountParcel(),
+                genEmptyFastPairEligibleAccountParcel(),
+                null,
+                genHappyPathFastPairEligibleAccountParcel()};
+
+        List<Account> accountList = Utils.convertToAccountList(array);
+        assertThat(accountList.size()).isEqualTo(2);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathConvertToGetObservedDeviceResponse() {
+        Rpcs.GetObservedDeviceResponse response =
+                Utils.convertToGetObservedDeviceResponse(
+                        genHappyPathFastPairAntispoofKeyDeviceMetadataParcel());
+        assertThat(response).isEqualTo(genHappyPathObservedDeviceResponse());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToGetObservedDeviceResponseWithNullInput() {
+        assertThat(Utils.convertToGetObservedDeviceResponse(null))
+                .isEqualTo(null);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToGetObservedDeviceResponseWithEmptyInputNotCrash() {
+        Utils.convertToGetObservedDeviceResponse(
+                genEmptyFastPairAntispoofKeyDeviceMetadataParcel());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToGetObservedDeviceResponseWithEmptyDeviceMetadataNotCrash() {
+        Utils.convertToGetObservedDeviceResponse(
+                genFastPairAntispoofKeyDeviceMetadataParcelWithEmptyDeviceMetadata());
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testHappyPathConvertToFastPairAccountKeyDeviceMetadata() {
+        FastPairAccountKeyDeviceMetadataParcel metadataParcel =
+                Utils.convertToFastPairAccountKeyDeviceMetadata(genHappyPathFastPairUploadInfo());
+        ensureHappyPathAsExpected(metadataParcel);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToFastPairAccountKeyDeviceMetadataWithNullInput() {
+        assertThat(Utils.convertToFastPairAccountKeyDeviceMetadata(null)).isEqualTo(null);
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 32, codeName = "T")
+    public void testConvertToFastPairAccountKeyDeviceMetadataWithEmptyFieldsNotCrash() {
+        Utils.convertToFastPairAccountKeyDeviceMetadata(
+                new FastPairUploadInfo(
+                        null /* discoveryItem */,
+                        null /* accountKey */,
+                        null /* sha256AccountKeyPublicAddress */));
+    }
+
+    private static FastPairUploadInfo genHappyPathFastPairUploadInfo() {
+        return new FastPairUploadInfo(
+                genHappyPathStoredDiscoveryItem(),
+                ByteString.copyFrom(ACCOUNT_KEY),
+                ByteString.copyFrom(SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS));
+
+    }
+
+    private static void ensureHappyPathAsExpected(
+            FastPairAccountKeyDeviceMetadataParcel metadataParcel) {
+        assertThat(metadataParcel).isNotNull();
+        assertThat(metadataParcel.deviceAccountKey).isEqualTo(ACCOUNT_KEY);
+        assertThat(metadataParcel.sha256DeviceAccountKeyPublicAddress)
+                .isEqualTo(SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS);
+        ensureHappyPathAsExpected(metadataParcel.metadata);
+        ensureHappyPathAsExpected(metadataParcel.discoveryItem);
+    }
+
+    private static void ensureHappyPathAsExpected(FastPairDeviceMetadataParcel metadataParcel) {
+        assertThat(metadataParcel).isNotNull();
+
+        assertThat(metadataParcel.assistantSetupHalfSheet).isEqualTo(ASSISTANT_SETUP_HALFSHEET);
+        assertThat(metadataParcel.assistantSetupNotification).isEqualTo(
+                ASSISTANT_SETUP_NOTIFICATION);
+
+
+        assertThat(metadataParcel.confirmPinDescription).isEqualTo(CONFIRM_PIN_DESCRIPTION);
+        assertThat(metadataParcel.confirmPinTitle).isEqualTo(CONFIRM_PIN_TITLE);
+        assertThat(metadataParcel.connectSuccessCompanionAppInstalled).isEqualTo(
+                CONNECT_SUCCESS_COMPANION_APP_INSTALLED);
+        assertThat(metadataParcel.connectSuccessCompanionAppNotInstalled).isEqualTo(
+                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED);
+
+        assertThat(metadataParcel.deviceType).isEqualTo(DEVICE_TYPE);
+
+        assertThat(metadataParcel.failConnectGoToSettingsDescription).isEqualTo(
+                FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION);
+        assertThat(metadataParcel.fastPairTvConnectDeviceNoAccountDescription).isEqualTo(
+                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+
+        assertThat(metadataParcel.initialNotificationDescription).isEqualTo(
+                INITIAL_NOTIFICATION_DESCRIPTION);
+        assertThat(metadataParcel.initialNotificationDescriptionNoAccount).isEqualTo(
+                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT);
+        assertThat(metadataParcel.initialPairingDescription).isEqualTo(INITIAL_PAIRING_DESCRIPTION);
+
+
+        assertThat(metadataParcel.retroactivePairingDescription).isEqualTo(
+                RETRO_ACTIVE_PAIRING_DESCRIPTION);
+
+        assertThat(metadataParcel.subsequentPairingDescription).isEqualTo(
+                SUBSEQUENT_PAIRING_DESCRIPTION);
+        assertThat(metadataParcel.syncContactsDescription).isEqualTo(SYNC_CONTACT_DESCRPTION);
+        assertThat(metadataParcel.syncContactsTitle).isEqualTo(SYNC_CONTACTS_TITLE);
+        assertThat(metadataParcel.syncSmsDescription).isEqualTo(SYNC_SMS_DESCRIPTION);
+        assertThat(metadataParcel.syncSmsTitle).isEqualTo(SYNC_SMS_TITLE);
+
+        assertThat(metadataParcel.trueWirelessImageUrlCase).isEqualTo(TRUE_WIRELESS_IMAGE_URL_CASE);
+        assertThat(metadataParcel.trueWirelessImageUrlLeftBud).isEqualTo(
+                TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        assertThat(metadataParcel.trueWirelessImageUrlRightBud).isEqualTo(
+                TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+        assertThat(metadataParcel.waitLaunchCompanionAppDescription).isEqualTo(
+                WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+
+        /* do we need upload this? */
+        // assertThat(metadataParcel.locale).isEqualTo(LOCALE);
+        // assertThat(metadataParcel.name).isEqualTo(NAME);
+        // assertThat(metadataParcel.downloadCompanionAppDescription).isEqualTo(
+        //        DOWNLOAD_COMPANION_APP_DESCRIPTION);
+        // assertThat(metadataParcel.openCompanionAppDescription).isEqualTo(
+        //        OPEN_COMPANION_APP_DESCRIPTION);
+        // assertThat(metadataParcel.triggerDistance).isWithin(DELTA).of(TRIGGER_DISTANCE);
+        // assertThat(metadataParcel.unableToConnectDescription).isEqualTo(
+        //        UNABLE_TO_CONNECT_DESCRIPTION);
+        // assertThat(metadataParcel.unableToConnectTitle).isEqualTo(UNABLE_TO_CONNECT_TITLE);
+        // assertThat(metadataParcel.updateCompanionAppDescription).isEqualTo(
+        //        UPDATE_COMPANION_APP_DESCRIPTION);
+
+        // assertThat(metadataParcel.bleTxPower).isEqualTo(BLE_TX_POWER);
+        // assertThat(metadataParcel.image).isEqualTo(IMAGE);
+        // assertThat(metadataParcel.imageUrl).isEqualTo(IMAGE_URL);
+        // assertThat(metadataParcel.intentUri).isEqualTo(INTENT_URI);
+    }
+
+    private static void ensureHappyPathAsExpected(FastPairDiscoveryItemParcel itemParcel) {
+        assertThat(itemParcel.actionUrl).isEqualTo(ACTION_URL);
+        assertThat(itemParcel.actionUrlType).isEqualTo(ACTION_URL_TYPE);
+        assertThat(itemParcel.appName).isEqualTo(APP_NAME);
+        assertThat(itemParcel.attachmentType).isEqualTo(ATTACHMENT_TYPE);
+        assertThat(itemParcel.authenticationPublicKeySecp256r1)
+                .isEqualTo(AUTHENTICATION_PUBLIC_KEY_SEC_P256R1);
+        assertThat(itemParcel.bleRecordBytes).isEqualTo(BLE_RECORD_BYTES);
+        assertThat(itemParcel.debugCategory).isEqualTo(DEBUG_CATEGORY);
+        assertThat(itemParcel.debugMessage).isEqualTo(DEBUG_MESSAGE);
+        assertThat(itemParcel.description).isEqualTo(DESCRIPTION);
+        assertThat(itemParcel.deviceName).isEqualTo(DEVICE_NAME);
+        assertThat(itemParcel.displayUrl).isEqualTo(DISPLAY_URL);
+        assertThat(itemParcel.entityId).isEqualTo(ENTITY_ID);
+        assertThat(itemParcel.featureGraphicUrl).isEqualTo(FEATURE_GRAPHIC_URL);
+        assertThat(itemParcel.firstObservationTimestampMillis)
+                .isEqualTo(FIRST_OBSERVATION_TIMESTAMP_MILLIS);
+        assertThat(itemParcel.groupId).isEqualTo(GROUP_ID);
+        assertThat(itemParcel.iconFifeUrl).isEqualTo(ICON_FIFE_URL);
+        assertThat(itemParcel.iconPng).isEqualTo(ICON_PNG);
+        assertThat(itemParcel.id).isEqualTo(ID);
+        assertThat(itemParcel.lastObservationTimestampMillis)
+                .isEqualTo(LAST_OBSERVATION_TIMESTAMP_MILLIS);
+        assertThat(itemParcel.lastUserExperience).isEqualTo(LAST_USER_EXPERIENCE);
+        assertThat(itemParcel.lostMillis).isEqualTo(LOST_MILLIS);
+        assertThat(itemParcel.macAddress).isEqualTo(MAC_ADDRESS);
+        assertThat(itemParcel.packageName).isEqualTo(PACKAGE_NAME);
+        assertThat(itemParcel.pendingAppInstallTimestampMillis)
+                .isEqualTo(PENDING_APP_INSTALL_TIMESTAMP_MILLIS);
+        assertThat(itemParcel.rssi).isEqualTo(RSSI);
+        assertThat(itemParcel.state).isEqualTo(STATE);
+        assertThat(itemParcel.title).isEqualTo(TITLE);
+        assertThat(itemParcel.triggerId).isEqualTo(TRIGGER_ID);
+        assertThat(itemParcel.txPower).isEqualTo(TX_POWER);
+        assertThat(itemParcel.type).isEqualTo(TYPE);
+    }
+
+    private static FastPairEligibleAccountParcel genHappyPathFastPairEligibleAccountParcel() {
+        FastPairEligibleAccountParcel parcel = new FastPairEligibleAccountParcel();
+        parcel.account = ELIGIBLE_ACCOUNT_1;
+        parcel.optIn = true;
+
+        return parcel;
+    }
+
+    private static FastPairEligibleAccountParcel genEmptyFastPairEligibleAccountParcel() {
+        return new FastPairEligibleAccountParcel();
+    }
+
+    private static FastPairDeviceMetadataParcel genEmptyFastPairDeviceMetadataParcel() {
+        return new FastPairDeviceMetadataParcel();
+    }
+
+    private static FastPairDiscoveryItemParcel genEmptyFastPairDiscoveryItemParcel() {
+        return new FastPairDiscoveryItemParcel();
+    }
+
+    private static FastPairAccountKeyDeviceMetadataParcel
+            genEmptyFastPairAccountkeyDeviceMetadataParcel() {
+        return new FastPairAccountKeyDeviceMetadataParcel();
+    }
+
+    private static FastPairAccountKeyDeviceMetadataParcel
+            genFastPairAccountkeyDeviceMetadataParcelWithEmptyMetadataDiscoveryItem() {
+        FastPairAccountKeyDeviceMetadataParcel parcel =
+                new FastPairAccountKeyDeviceMetadataParcel();
+        parcel.metadata = genEmptyFastPairDeviceMetadataParcel();
+        parcel.discoveryItem = genEmptyFastPairDiscoveryItemParcel();
+
+        return parcel;
+    }
+
+    private static FastPairAccountKeyDeviceMetadataParcel
+            genHappyPathFastPairAccountkeyDeviceMetadataParcel() {
+        FastPairAccountKeyDeviceMetadataParcel parcel =
+                new FastPairAccountKeyDeviceMetadataParcel();
+        parcel.deviceAccountKey = ACCOUNT_KEY;
+        parcel.metadata = genHappyPathFastPairDeviceMetadataParcel();
+        parcel.sha256DeviceAccountKeyPublicAddress = SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS;
+        parcel.discoveryItem = genHappyPathFastPairDiscoveryItemParcel();
+
+        return parcel;
+    }
+
+    private static FastPairDeviceMetadataParcel genHappyPathFastPairDeviceMetadataParcel() {
+        FastPairDeviceMetadataParcel parcel = new FastPairDeviceMetadataParcel();
+
+        parcel.assistantSetupHalfSheet = ASSISTANT_SETUP_HALFSHEET;
+        parcel.assistantSetupNotification = ASSISTANT_SETUP_NOTIFICATION;
+        parcel.bleTxPower = BLE_TX_POWER;
+        parcel.confirmPinDescription = CONFIRM_PIN_DESCRIPTION;
+        parcel.confirmPinTitle = CONFIRM_PIN_TITLE;
+        parcel.connectSuccessCompanionAppInstalled = CONNECT_SUCCESS_COMPANION_APP_INSTALLED;
+        parcel.connectSuccessCompanionAppNotInstalled =
+                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED;
+        parcel.deviceType = DEVICE_TYPE;
+        parcel.downloadCompanionAppDescription = DOWNLOAD_COMPANION_APP_DESCRIPTION;
+        parcel.failConnectGoToSettingsDescription = FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION;
+        parcel.fastPairTvConnectDeviceNoAccountDescription =
+                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION;
+        parcel.image = IMAGE;
+        parcel.imageUrl = IMAGE_URL;
+        parcel.initialNotificationDescription = INITIAL_NOTIFICATION_DESCRIPTION;
+        parcel.initialNotificationDescriptionNoAccount =
+                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT;
+        parcel.initialPairingDescription = INITIAL_PAIRING_DESCRIPTION;
+        parcel.intentUri = INTENT_URI;
+        parcel.locale = LOCALE;
+        parcel.name = NAME;
+        parcel.openCompanionAppDescription = OPEN_COMPANION_APP_DESCRIPTION;
+        parcel.retroactivePairingDescription = RETRO_ACTIVE_PAIRING_DESCRIPTION;
+        parcel.subsequentPairingDescription = SUBSEQUENT_PAIRING_DESCRIPTION;
+        parcel.syncContactsDescription = SYNC_CONTACT_DESCRPTION;
+        parcel.syncContactsTitle = SYNC_CONTACTS_TITLE;
+        parcel.syncSmsDescription = SYNC_SMS_DESCRIPTION;
+        parcel.syncSmsTitle = SYNC_SMS_TITLE;
+        parcel.triggerDistance = TRIGGER_DISTANCE;
+        parcel.trueWirelessImageUrlCase = TRUE_WIRELESS_IMAGE_URL_CASE;
+        parcel.trueWirelessImageUrlLeftBud = TRUE_WIRELESS_IMAGE_URL_LEFT_BUD;
+        parcel.trueWirelessImageUrlRightBud = TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD;
+        parcel.unableToConnectDescription = UNABLE_TO_CONNECT_DESCRIPTION;
+        parcel.unableToConnectTitle = UNABLE_TO_CONNECT_TITLE;
+        parcel.updateCompanionAppDescription = UPDATE_COMPANION_APP_DESCRIPTION;
+        parcel.waitLaunchCompanionAppDescription = WAIT_LAUNCH_COMPANION_APP_DESCRIPTION;
+
+        return parcel;
+    }
+
+    private static Cache.StoredDiscoveryItem genHappyPathStoredDiscoveryItem() {
+        Cache.StoredDiscoveryItem.Builder storedDiscoveryItemBuilder =
+                Cache.StoredDiscoveryItem.newBuilder();
+        storedDiscoveryItemBuilder.setActionUrl(ACTION_URL);
+        storedDiscoveryItemBuilder.setActionUrlType(Cache.ResolvedUrlType.WEBPAGE);
+        storedDiscoveryItemBuilder.setAppName(APP_NAME);
+        storedDiscoveryItemBuilder.setAttachmentType(
+                Cache.DiscoveryAttachmentType.DISCOVERY_ATTACHMENT_TYPE_NORMAL);
+        storedDiscoveryItemBuilder.setAuthenticationPublicKeySecp256R1(
+                ByteString.copyFrom(AUTHENTICATION_PUBLIC_KEY_SEC_P256R1));
+        storedDiscoveryItemBuilder.setBleRecordBytes(ByteString.copyFrom(BLE_RECORD_BYTES));
+        storedDiscoveryItemBuilder.setDebugCategory(
+                Cache.StoredDiscoveryItem.DebugMessageCategory.STATUS_VALID_NOTIFICATION);
+        storedDiscoveryItemBuilder.setDebugMessage(DEBUG_MESSAGE);
+        storedDiscoveryItemBuilder.setDescription(DESCRIPTION);
+        storedDiscoveryItemBuilder.setDeviceName(DEVICE_NAME);
+        storedDiscoveryItemBuilder.setDisplayUrl(DISPLAY_URL);
+        storedDiscoveryItemBuilder.setEntityId(ENTITY_ID);
+        storedDiscoveryItemBuilder.setFeatureGraphicUrl(FEATURE_GRAPHIC_URL);
+        storedDiscoveryItemBuilder.setFirstObservationTimestampMillis(
+                FIRST_OBSERVATION_TIMESTAMP_MILLIS);
+        storedDiscoveryItemBuilder.setGroupId(GROUP_ID);
+        storedDiscoveryItemBuilder.setIconFifeUrl(ICON_FIFE_URL);
+        storedDiscoveryItemBuilder.setIconPng(ByteString.copyFrom(ICON_PNG));
+        storedDiscoveryItemBuilder.setId(ID);
+        storedDiscoveryItemBuilder.setLastObservationTimestampMillis(
+                LAST_OBSERVATION_TIMESTAMP_MILLIS);
+        storedDiscoveryItemBuilder.setLastUserExperience(
+                Cache.StoredDiscoveryItem.ExperienceType.EXPERIENCE_GOOD);
+        storedDiscoveryItemBuilder.setLostMillis(LOST_MILLIS);
+        storedDiscoveryItemBuilder.setMacAddress(MAC_ADDRESS);
+        storedDiscoveryItemBuilder.setPackageName(PACKAGE_NAME);
+        storedDiscoveryItemBuilder.setPendingAppInstallTimestampMillis(
+                PENDING_APP_INSTALL_TIMESTAMP_MILLIS);
+        storedDiscoveryItemBuilder.setRssi(RSSI);
+        storedDiscoveryItemBuilder.setState(Cache.StoredDiscoveryItem.State.STATE_ENABLED);
+        storedDiscoveryItemBuilder.setTitle(TITLE);
+        storedDiscoveryItemBuilder.setTriggerId(TRIGGER_ID);
+        storedDiscoveryItemBuilder.setTxPower(TX_POWER);
+        storedDiscoveryItemBuilder.setType(Cache.NearbyType.NEARBY_PROXIMITY_BEACON);
+
+        FastPairStrings.Builder stringsBuilder = FastPairStrings.newBuilder();
+        stringsBuilder.setAssistantHalfSheetDescription(ASSISTANT_SETUP_HALFSHEET);
+        stringsBuilder.setAssistantNotificationDescription(ASSISTANT_SETUP_NOTIFICATION);
+        stringsBuilder.setConfirmPinDescription(CONFIRM_PIN_DESCRIPTION);
+        stringsBuilder.setConfirmPinTitle(CONFIRM_PIN_TITLE);
+        stringsBuilder.setPairingFinishedCompanionAppInstalled(
+                CONNECT_SUCCESS_COMPANION_APP_INSTALLED);
+        stringsBuilder.setPairingFinishedCompanionAppNotInstalled(
+                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED);
+        stringsBuilder.setPairingFailDescription(
+                FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION);
+        stringsBuilder.setFastPairTvConnectDeviceNoAccountDescription(
+                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+        stringsBuilder.setTapToPairWithAccount(
+                INITIAL_NOTIFICATION_DESCRIPTION);
+        stringsBuilder.setTapToPairWithoutAccount(
+                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT);
+        stringsBuilder.setInitialPairingDescription(INITIAL_PAIRING_DESCRIPTION);
+        stringsBuilder.setRetroactivePairingDescription(RETRO_ACTIVE_PAIRING_DESCRIPTION);
+        stringsBuilder.setSubsequentPairingDescription(SUBSEQUENT_PAIRING_DESCRIPTION);
+        stringsBuilder.setSyncContactsDescription(SYNC_CONTACT_DESCRPTION);
+        stringsBuilder.setSyncContactsTitle(SYNC_CONTACTS_TITLE);
+        stringsBuilder.setSyncSmsDescription(SYNC_SMS_DESCRIPTION);
+        stringsBuilder.setSyncSmsTitle(SYNC_SMS_TITLE);
+        stringsBuilder.setWaitAppLaunchDescription(WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+        storedDiscoveryItemBuilder.setFastPairStrings(stringsBuilder.build());
+
+        Cache.FastPairInformation.Builder fpInformationBuilder =
+                Cache.FastPairInformation.newBuilder();
+        Rpcs.TrueWirelessHeadsetImages.Builder imagesBuilder =
+                Rpcs.TrueWirelessHeadsetImages.newBuilder();
+        imagesBuilder.setCaseUrl(TRUE_WIRELESS_IMAGE_URL_CASE);
+        imagesBuilder.setLeftBudUrl(TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        imagesBuilder.setRightBudUrl(TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+        fpInformationBuilder.setTrueWirelessImages(imagesBuilder.build());
+        fpInformationBuilder.setDeviceType(Rpcs.DeviceType.HEADPHONES);
+
+        storedDiscoveryItemBuilder.setFastPairInformation(fpInformationBuilder.build());
+        storedDiscoveryItemBuilder.setTxPower(TX_POWER);
+
+        storedDiscoveryItemBuilder.setIconPng(ByteString.copyFrom(ICON_PNG));
+        storedDiscoveryItemBuilder.setIconFifeUrl(ICON_FIFE_URL);
+        storedDiscoveryItemBuilder.setActionUrl(ACTION_URL);
+
+        return storedDiscoveryItemBuilder.build();
+    }
+
+    private static Data.FastPairDeviceWithAccountKey genHappyPathFastPairDeviceWithAccountKey() {
+        Data.FastPairDeviceWithAccountKey.Builder fpDeviceBuilder =
+                Data.FastPairDeviceWithAccountKey.newBuilder();
+        fpDeviceBuilder.setAccountKey(ByteString.copyFrom(ACCOUNT_KEY));
+        fpDeviceBuilder.setSha256AccountKeyPublicAddress(
+                ByteString.copyFrom(SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS));
+        fpDeviceBuilder.setDiscoveryItem(genHappyPathStoredDiscoveryItem());
+
+        return fpDeviceBuilder.build();
+    }
+
+    private static FastPairDiscoveryItemParcel genHappyPathFastPairDiscoveryItemParcel() {
+        FastPairDiscoveryItemParcel parcel = new FastPairDiscoveryItemParcel();
+        parcel.actionUrl = ACTION_URL;
+        parcel.actionUrlType = ACTION_URL_TYPE;
+        parcel.appName = APP_NAME;
+        parcel.attachmentType = ATTACHMENT_TYPE;
+        parcel.authenticationPublicKeySecp256r1 = AUTHENTICATION_PUBLIC_KEY_SEC_P256R1;
+        parcel.bleRecordBytes = BLE_RECORD_BYTES;
+        parcel.debugCategory = DEBUG_CATEGORY;
+        parcel.debugMessage = DEBUG_MESSAGE;
+        parcel.description = DESCRIPTION;
+        parcel.deviceName = DEVICE_NAME;
+        parcel.displayUrl = DISPLAY_URL;
+        parcel.entityId = ENTITY_ID;
+        parcel.featureGraphicUrl = FEATURE_GRAPHIC_URL;
+        parcel.firstObservationTimestampMillis = FIRST_OBSERVATION_TIMESTAMP_MILLIS;
+        parcel.groupId = GROUP_ID;
+        parcel.iconFifeUrl = ICON_FIFE_URL;
+        parcel.iconPng = ICON_PNG;
+        parcel.id = ID;
+        parcel.lastObservationTimestampMillis = LAST_OBSERVATION_TIMESTAMP_MILLIS;
+        parcel.lastUserExperience = LAST_USER_EXPERIENCE;
+        parcel.lostMillis = LOST_MILLIS;
+        parcel.macAddress = MAC_ADDRESS;
+        parcel.packageName = PACKAGE_NAME;
+        parcel.pendingAppInstallTimestampMillis = PENDING_APP_INSTALL_TIMESTAMP_MILLIS;
+        parcel.rssi = RSSI;
+        parcel.state = STATE;
+        parcel.title = TITLE;
+        parcel.triggerId = TRIGGER_ID;
+        parcel.txPower = TX_POWER;
+        parcel.type = TYPE;
+
+        return parcel;
+    }
+
+    private static Rpcs.GetObservedDeviceResponse genHappyPathObservedDeviceResponse() {
+        Rpcs.Device.Builder deviceBuilder = Rpcs.Device.newBuilder();
+        deviceBuilder.setAntiSpoofingKeyPair(Rpcs.AntiSpoofingKeyPair.newBuilder()
+                .setPublicKey(ByteString.copyFrom(ANTI_SPOOFING_KEY))
+                .build());
+        Rpcs.TrueWirelessHeadsetImages.Builder imagesBuilder =
+                Rpcs.TrueWirelessHeadsetImages.newBuilder();
+        imagesBuilder.setLeftBudUrl(TRUE_WIRELESS_IMAGE_URL_LEFT_BUD);
+        imagesBuilder.setRightBudUrl(TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD);
+        imagesBuilder.setCaseUrl(TRUE_WIRELESS_IMAGE_URL_CASE);
+        deviceBuilder.setTrueWirelessImages(imagesBuilder.build());
+        deviceBuilder.setImageUrl(IMAGE_URL);
+        deviceBuilder.setIntentUri(INTENT_URI);
+        deviceBuilder.setName(NAME);
+        deviceBuilder.setBleTxPower(BLE_TX_POWER);
+        deviceBuilder.setTriggerDistance(TRIGGER_DISTANCE);
+        deviceBuilder.setDeviceType(Rpcs.DeviceType.HEADPHONES);
+
+        return Rpcs.GetObservedDeviceResponse.newBuilder()
+                .setDevice(deviceBuilder.build())
+                .setImage(ByteString.copyFrom(IMAGE))
+                .setStrings(Rpcs.ObservedDeviceStrings.newBuilder()
+                        .setAssistantSetupHalfSheet(ASSISTANT_SETUP_HALFSHEET)
+                        .setAssistantSetupNotification(ASSISTANT_SETUP_NOTIFICATION)
+                        .setConfirmPinDescription(CONFIRM_PIN_DESCRIPTION)
+                        .setConfirmPinTitle(CONFIRM_PIN_TITLE)
+                        .setConnectSuccessCompanionAppInstalled(
+                                CONNECT_SUCCESS_COMPANION_APP_INSTALLED)
+                        .setConnectSuccessCompanionAppNotInstalled(
+                                CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED)
+                        .setDownloadCompanionAppDescription(
+                                DOWNLOAD_COMPANION_APP_DESCRIPTION)
+                        .setFailConnectGoToSettingsDescription(
+                                FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION)
+                        .setFastPairTvConnectDeviceNoAccountDescription(
+                                FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION)
+                        .setInitialNotificationDescription(
+                                INITIAL_NOTIFICATION_DESCRIPTION)
+                        .setInitialNotificationDescriptionNoAccount(
+                                INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT)
+                        .setInitialPairingDescription(
+                                INITIAL_PAIRING_DESCRIPTION)
+                        .setLocale(LOCALE)
+                        .setOpenCompanionAppDescription(
+                                OPEN_COMPANION_APP_DESCRIPTION)
+                        .setRetroactivePairingDescription(
+                                RETRO_ACTIVE_PAIRING_DESCRIPTION)
+                        .setSubsequentPairingDescription(
+                                SUBSEQUENT_PAIRING_DESCRIPTION)
+                        .setSyncContactsDescription(
+                                SYNC_CONTACT_DESCRPTION)
+                        .setSyncContactsTitle(
+                                SYNC_CONTACTS_TITLE)
+                        .setSyncSmsDescription(
+                                SYNC_SMS_DESCRIPTION)
+                        .setSyncSmsTitle(
+                                SYNC_SMS_TITLE)
+                        .setUnableToConnectDescription(
+                                UNABLE_TO_CONNECT_DESCRIPTION)
+                        .setUnableToConnectTitle(
+                                UNABLE_TO_CONNECT_TITLE)
+                        .setUpdateCompanionAppDescription(
+                                UPDATE_COMPANION_APP_DESCRIPTION)
+                        .setWaitLaunchCompanionAppDescription(
+                                WAIT_LAUNCH_COMPANION_APP_DESCRIPTION)
+                        .build())
+                .build();
+    }
+
+    private static FastPairAntispoofKeyDeviceMetadataParcel
+            genHappyPathFastPairAntispoofKeyDeviceMetadataParcel() {
+        FastPairAntispoofKeyDeviceMetadataParcel parcel =
+                new FastPairAntispoofKeyDeviceMetadataParcel();
+        parcel.antispoofPublicKey = ANTI_SPOOFING_KEY;
+        parcel.deviceMetadata = genHappyPathFastPairDeviceMetadataParcel();
+
+        return parcel;
+    }
+
+    private static FastPairAntispoofKeyDeviceMetadataParcel
+            genFastPairAntispoofKeyDeviceMetadataParcelWithEmptyDeviceMetadata() {
+        FastPairAntispoofKeyDeviceMetadataParcel parcel =
+                new FastPairAntispoofKeyDeviceMetadataParcel();
+        parcel.antispoofPublicKey = ANTI_SPOOFING_KEY;
+        parcel.deviceMetadata = genEmptyFastPairDeviceMetadataParcel();
+
+        return parcel;
+    }
+
+    private static FastPairAntispoofKeyDeviceMetadataParcel
+            genEmptyFastPairAntispoofKeyDeviceMetadataParcel() {
+        return new FastPairAntispoofKeyDeviceMetadataParcel();
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/DataUtilsTest.java b/nearby/tests/unit/src/com/android/server/nearby/util/DataUtilsTest.java
new file mode 100644
index 0000000..9152c07
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/DataUtilsTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.protobuf.ByteString;
+
+import org.junit.Test;
+
+import service.proto.Cache;
+import service.proto.FastPairString.FastPairStrings;
+import service.proto.Rpcs;
+import service.proto.Rpcs.GetObservedDeviceResponse;
+
+public final class DataUtilsTest {
+    private static final String BLUETOOTH_ADDRESS = "00:11:22:33:FF:EE";
+    private static final String APP_PACKAGE = "test_package";
+    private static final String APP_ACTION_URL =
+            "intent:#Intent;action=cto_be_set%3AACTION_MAGIC_PAIR;"
+                    + "package=to_be_set;"
+                    + "component=to_be_set;"
+                    + "to_be_set%3AEXTRA_COMPANION_APP="
+                    + APP_PACKAGE
+                    + ";end";
+    private static final long DEVICE_ID = 12;
+    private static final String DEVICE_NAME = "My device";
+    private static final byte[] DEVICE_PUBLIC_KEY = base16().decode("0123456789ABCDEF");
+    private static final String DEVICE_COMPANY = "Company name";
+    private static final byte[] DEVICE_IMAGE = new byte[] {0x00, 0x01, 0x10, 0x11};
+    private static final String DEVICE_IMAGE_URL = "device_image_url";
+    private static final String AUTHORITY = "com.android.test";
+    private static final String SIGNATURE_HASH = "as8dfbyu2duas7ikanvklpaclo2";
+    private static final String ACCOUNT = "test@gmail.com";
+
+    private static final String MESSAGE_INIT_NOTIFY_DESCRIPTION = "message 1";
+    private static final String MESSAGE_INIT_NOTIFY_DESCRIPTION_NO_ACCOUNT = "message 2";
+    private static final String MESSAGE_INIT_PAIR_DESCRIPTION = "message 3 %s";
+    private static final String MESSAGE_COMPANION_INSTALLED = "message 4";
+    private static final String MESSAGE_COMPANION_NOT_INSTALLED = "message 5";
+    private static final String MESSAGE_SUBSEQUENT_PAIR_DESCRIPTION = "message 6";
+    private static final String MESSAGE_RETROACTIVE_PAIR_DESCRIPTION = "message 7";
+    private static final String MESSAGE_WAIT_LAUNCH_COMPANION_APP_DESCRIPTION = "message 8";
+    private static final String MESSAGE_FAIL_CONNECT_DESCRIPTION = "message 9";
+    private static final String MESSAGE_FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION =
+            "message 10";
+    private static final String MESSAGE_ASSISTANT_HALF_SHEET_DESCRIPTION = "message 11";
+    private static final String MESSAGE_ASSISTANT_NOTIFICATION_DESCRIPTION = "message 12";
+
+    @Test
+    public void test_toScanFastPairStoreItem_withAccount() {
+        Cache.ScanFastPairStoreItem item = DataUtils.toScanFastPairStoreItem(
+                createObservedDeviceResponse(), BLUETOOTH_ADDRESS, ACCOUNT);
+        assertThat(item.getAddress()).isEqualTo(BLUETOOTH_ADDRESS);
+        assertThat(item.getActionUrl()).isEqualTo(APP_ACTION_URL);
+        assertThat(item.getDeviceName()).isEqualTo(DEVICE_NAME);
+        assertThat(item.getIconPng()).isEqualTo(ByteString.copyFrom(DEVICE_IMAGE));
+        assertThat(item.getIconFifeUrl()).isEqualTo(DEVICE_IMAGE_URL);
+        assertThat(item.getAntiSpoofingPublicKey())
+                .isEqualTo(ByteString.copyFrom(DEVICE_PUBLIC_KEY));
+
+        FastPairStrings strings = item.getFastPairStrings();
+        assertThat(strings.getTapToPairWithAccount()).isEqualTo(MESSAGE_INIT_NOTIFY_DESCRIPTION);
+        assertThat(strings.getTapToPairWithoutAccount())
+                .isEqualTo(MESSAGE_INIT_NOTIFY_DESCRIPTION_NO_ACCOUNT);
+        assertThat(strings.getInitialPairingDescription())
+                .isEqualTo(String.format(MESSAGE_INIT_PAIR_DESCRIPTION, DEVICE_NAME));
+        assertThat(strings.getPairingFinishedCompanionAppInstalled())
+                .isEqualTo(MESSAGE_COMPANION_INSTALLED);
+        assertThat(strings.getPairingFinishedCompanionAppNotInstalled())
+                .isEqualTo(MESSAGE_COMPANION_NOT_INSTALLED);
+        assertThat(strings.getSubsequentPairingDescription())
+                .isEqualTo(MESSAGE_SUBSEQUENT_PAIR_DESCRIPTION);
+        assertThat(strings.getRetroactivePairingDescription())
+                .isEqualTo(MESSAGE_RETROACTIVE_PAIR_DESCRIPTION);
+        assertThat(strings.getWaitAppLaunchDescription())
+                .isEqualTo(MESSAGE_WAIT_LAUNCH_COMPANION_APP_DESCRIPTION);
+        assertThat(strings.getPairingFailDescription())
+                .isEqualTo(MESSAGE_FAIL_CONNECT_DESCRIPTION);
+        assertThat(strings.getAssistantHalfSheetDescription())
+                .isEqualTo(MESSAGE_ASSISTANT_HALF_SHEET_DESCRIPTION);
+        assertThat(strings.getAssistantNotificationDescription())
+                .isEqualTo(MESSAGE_ASSISTANT_NOTIFICATION_DESCRIPTION);
+        assertThat(strings.getFastPairTvConnectDeviceNoAccountDescription())
+                .isEqualTo(MESSAGE_FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION);
+    }
+
+    @Test
+    public void test_toScanFastPairStoreItem_withoutAccount() {
+        Cache.ScanFastPairStoreItem item = DataUtils.toScanFastPairStoreItem(
+                createObservedDeviceResponse(), BLUETOOTH_ADDRESS, /* account= */ null);
+        FastPairStrings strings = item.getFastPairStrings();
+        assertThat(strings.getInitialPairingDescription())
+                .isEqualTo(MESSAGE_INIT_NOTIFY_DESCRIPTION_NO_ACCOUNT);
+    }
+
+    @Test
+    public void test_toString() {
+        Cache.ScanFastPairStoreItem item = DataUtils.toScanFastPairStoreItem(
+                createObservedDeviceResponse(), BLUETOOTH_ADDRESS, ACCOUNT);
+        FastPairStrings strings = item.getFastPairStrings();
+
+        assertThat(DataUtils.toString(strings))
+                .isEqualTo("FastPairStrings[tapToPairWithAccount=message 1, "
+                        + "tapToPairWithoutAccount=message 2, "
+                        + "initialPairingDescription=message 3 " + DEVICE_NAME + ", "
+                        + "pairingFinishedCompanionAppInstalled=message 4, "
+                        + "pairingFinishedCompanionAppNotInstalled=message 5, "
+                        + "subsequentPairingDescription=message 6, "
+                        + "retroactivePairingDescription=message 7, "
+                        + "waitAppLaunchDescription=message 8, "
+                        + "pairingFailDescription=message 9, "
+                        + "assistantHalfSheetDescription=message 11, "
+                        + "assistantNotificationDescription=message 12, "
+                        + "fastPairTvConnectDeviceNoAccountDescription=message 10]");
+    }
+
+    private static GetObservedDeviceResponse createObservedDeviceResponse() {
+        return GetObservedDeviceResponse.newBuilder()
+                .setDevice(
+                        Rpcs.Device.newBuilder()
+                                .setId(DEVICE_ID)
+                                .setName(DEVICE_NAME)
+                                .setAntiSpoofingKeyPair(
+                                        Rpcs.AntiSpoofingKeyPair
+                                                .newBuilder()
+                                                .setPublicKey(
+                                                        ByteString.copyFrom(DEVICE_PUBLIC_KEY)))
+                                .setIntentUri(APP_ACTION_URL)
+                                .setDataOnlyConnection(true)
+                                .setAssistantSupported(false)
+                                .setCompanionDetail(
+                                        Rpcs.CompanionAppDetails.newBuilder()
+                                                .setAuthority(AUTHORITY)
+                                                .setCertificateHash(SIGNATURE_HASH)
+                                                .build())
+                                .setCompanyName(DEVICE_COMPANY)
+                                .setImageUrl(DEVICE_IMAGE_URL))
+                .setImage(ByteString.copyFrom(DEVICE_IMAGE))
+                .setStrings(
+                        Rpcs.ObservedDeviceStrings.newBuilder()
+                                .setInitialNotificationDescription(MESSAGE_INIT_NOTIFY_DESCRIPTION)
+                                .setInitialNotificationDescriptionNoAccount(
+                                        MESSAGE_INIT_NOTIFY_DESCRIPTION_NO_ACCOUNT)
+                                .setInitialPairingDescription(MESSAGE_INIT_PAIR_DESCRIPTION)
+                                .setConnectSuccessCompanionAppInstalled(MESSAGE_COMPANION_INSTALLED)
+                                .setConnectSuccessCompanionAppNotInstalled(
+                                        MESSAGE_COMPANION_NOT_INSTALLED)
+                                .setSubsequentPairingDescription(
+                                        MESSAGE_SUBSEQUENT_PAIR_DESCRIPTION)
+                                .setRetroactivePairingDescription(
+                                        MESSAGE_RETROACTIVE_PAIR_DESCRIPTION)
+                                .setWaitLaunchCompanionAppDescription(
+                                        MESSAGE_WAIT_LAUNCH_COMPANION_APP_DESCRIPTION)
+                                .setFailConnectGoToSettingsDescription(
+                                        MESSAGE_FAIL_CONNECT_DESCRIPTION)
+                                .setAssistantSetupHalfSheet(
+                                        MESSAGE_ASSISTANT_HALF_SHEET_DESCRIPTION)
+                                .setAssistantSetupNotification(
+                                        MESSAGE_ASSISTANT_NOTIFICATION_DESCRIPTION)
+                                .setFastPairTvConnectDeviceNoAccountDescription(
+                                        MESSAGE_FAST_PAIR_TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION))
+                .build();
+    }
+}
diff --git a/service-t/Android.bp b/service-t/Android.bp
index 8851554..50dd995 100644
--- a/service-t/Android.bp
+++ b/service-t/Android.bp
@@ -19,6 +19,9 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+// Include build rules from Sources.bp
+build = ["Sources.bp"]
+
 // This builds T+ services depending on framework-connectivity-t
 // hidden symbols separately from the S+ services, to ensure that S+
 // services cannot accidentally depend on T+ hidden symbols from
diff --git a/service-t/Sources.bp b/service-t/Sources.bp
new file mode 100644
index 0000000..04866fb
--- /dev/null
+++ b/service-t/Sources.bp
@@ -0,0 +1,151 @@
+//
+// 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.
+//
+
+// NetworkStats related libraries.
+
+filegroup {
+    name: "services.connectivity-netstats-sources",
+    srcs: [
+        "src/com/android/server/net/NetworkIdentity*.java",
+        "src/com/android/server/net/NetworkStats*.java",
+        "src/com/android/server/net/BpfInterfaceMapUpdater.java",
+        "src/com/android/server/net/InterfaceMapValue.java",
+        "src/com/android/server/net/CookieTagMapKey.java",
+        "src/com/android/server/net/CookieTagMapValue.java",
+        "src/com/android/server/net/StatsMapKey.java",
+        "src/com/android/server/net/StatsMapValue.java",
+        "src/com/android/server/net/UidStatsMapKey.java",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// For test code only.
+filegroup {
+    name: "lib_networkStatsFactory_native",
+    srcs: [
+        "jni/com_android_server_net_NetworkStatsFactory.cpp",
+    ],
+    path: "jni",
+    visibility: [
+        "//packages/modules/Connectivity:__subpackages__",
+    ],
+}
+
+filegroup {
+    name: "services.connectivity-netstats-jni-sources",
+    srcs: [
+        "jni/com_android_server_net_NetworkStatsFactory.cpp",
+        "jni/com_android_server_net_NetworkStatsService.cpp",
+    ],
+    path: "jni",
+    visibility: [
+        "//packages/modules/Connectivity:__subpackages__",
+    ],
+}
+
+// Nsd related libraries.
+
+filegroup {
+    name: "services.connectivity-nsd-sources",
+    srcs: [
+        "src/com/android/server/INativeDaemon*.java",
+        "src/com/android/server/NativeDaemon*.java",
+        "src/com/android/server/Nsd*.java",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// IpSec related libraries.
+
+filegroup {
+    name: "services.connectivity-ipsec-sources",
+    srcs: [
+        "src/com/android/server/IpSecService.java",
+    ],
+    path: "src",
+    visibility: [
+        "//visibility:private",
+    ],
+}
+
+// Ethernet related libraries.
+
+filegroup {
+    name: "services.connectivity-ethernet-sources",
+    srcs: [
+        "src/com/android/server/net/DelayedDiskWrite.java",
+        "src/com/android/server/net/IpConfigStore.java",
+    ],
+    path: "src",
+    visibility: [
+        "//frameworks/opt/net/ethernet/tests",
+    ],
+}
+
+// Connectivity-T common libraries.
+
+// TODO: remove this empty filegroup.
+filegroup {
+    name: "services.connectivity-tiramisu-sources",
+    srcs: [],
+    path: "src",
+    visibility: ["//frameworks/base/services/core"],
+}
+
+filegroup {
+    name: "services.connectivity-tiramisu-updatable-sources",
+    srcs: [
+        ":services.connectivity-ethernet-sources",
+        ":services.connectivity-ipsec-sources",
+        ":services.connectivity-netstats-sources",
+        ":services.connectivity-nsd-sources",
+    ],
+    path: "src",
+    visibility: [
+        "//packages/modules/Connectivity:__subpackages__",
+    ],
+}
+
+cc_library_shared {
+    name: "libcom_android_net_module_util_jni",
+    min_sdk_version: "30",
+    cflags: [
+        "-Wall",
+        "-Werror",
+        "-Wno-unused-parameter",
+        "-Wthread-safety",
+    ],
+    srcs: [
+        "jni/onload.cpp",
+    ],
+    stl: "libc++_static",
+    static_libs: [
+        "libnet_utils_device_common_bpfjni",
+    ],
+    shared_libs: [
+        "liblog",
+        "libnativehelper",
+    ],
+    apex_available: [
+        "//apex_available:platform",
+    ],
+}
diff --git a/service-t/jni/com_android_server_net_NetworkStatsFactory.cpp b/service-t/jni/com_android_server_net_NetworkStatsFactory.cpp
new file mode 100644
index 0000000..8b6526f
--- /dev/null
+++ b/service-t/jni/com_android_server_net_NetworkStatsFactory.cpp
@@ -0,0 +1,362 @@
+/*
+ * Copyright (C) 2013 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 "NetworkStats"
+
+#include <errno.h>
+#include <inttypes.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <vector>
+
+#include <jni.h>
+
+#include <nativehelper/JNIHelp.h>
+#include <nativehelper/ScopedUtfChars.h>
+#include <nativehelper/ScopedLocalRef.h>
+#include <nativehelper/ScopedPrimitiveArray.h>
+
+#include <utils/Log.h>
+#include <utils/misc.h>
+
+#include "android-base/unique_fd.h"
+#include "bpf/BpfUtils.h"
+#include "netdbpf/BpfNetworkStats.h"
+
+using android::bpf::parseBpfNetworkStatsDetail;
+using android::bpf::stats_line;
+
+namespace android {
+
+static jclass gStringClass;
+
+static struct {
+    jfieldID size;
+    jfieldID capacity;
+    jfieldID iface;
+    jfieldID uid;
+    jfieldID set;
+    jfieldID tag;
+    jfieldID metered;
+    jfieldID roaming;
+    jfieldID defaultNetwork;
+    jfieldID rxBytes;
+    jfieldID rxPackets;
+    jfieldID txBytes;
+    jfieldID txPackets;
+    jfieldID operations;
+} gNetworkStatsClassInfo;
+
+static jobjectArray get_string_array(JNIEnv* env, jobject obj, jfieldID field, int size, bool grow)
+{
+    if (!grow) {
+        jobjectArray array = (jobjectArray)env->GetObjectField(obj, field);
+        if (array != NULL) {
+            return array;
+        }
+    }
+    return env->NewObjectArray(size, gStringClass, NULL);
+}
+
+static jintArray get_int_array(JNIEnv* env, jobject obj, jfieldID field, int size, bool grow)
+{
+    if (!grow) {
+        jintArray array = (jintArray)env->GetObjectField(obj, field);
+        if (array != NULL) {
+            return array;
+        }
+    }
+    return env->NewIntArray(size);
+}
+
+static jlongArray get_long_array(JNIEnv* env, jobject obj, jfieldID field, int size, bool grow)
+{
+    if (!grow) {
+        jlongArray array = (jlongArray)env->GetObjectField(obj, field);
+        if (array != NULL) {
+            return array;
+        }
+    }
+    return env->NewLongArray(size);
+}
+
+static int legacyReadNetworkStatsDetail(std::vector<stats_line>* lines,
+                                        const std::vector<std::string>& limitIfaces,
+                                        int limitTag, int limitUid, const char* path) {
+    FILE* fp = fopen(path, "re");
+    if (fp == NULL) {
+        return -1;
+    }
+
+    int lastIdx = 1;
+    int idx;
+    char buffer[384];
+    while (fgets(buffer, sizeof(buffer), fp) != NULL) {
+        stats_line s;
+        int64_t rawTag;
+        char* pos = buffer;
+        char* endPos;
+        // First field is the index.
+        idx = (int)strtol(pos, &endPos, 10);
+        //ALOGI("Index #%d: %s", idx, buffer);
+        if (pos == endPos) {
+            // Skip lines that don't start with in index.  In particular,
+            // this will skip the initial header line.
+            continue;
+        }
+        if (idx != lastIdx + 1) {
+            ALOGE("inconsistent idx=%d after lastIdx=%d: %s", idx, lastIdx, buffer);
+            fclose(fp);
+            return -1;
+        }
+        lastIdx = idx;
+        pos = endPos;
+        // Skip whitespace.
+        while (*pos == ' ') {
+            pos++;
+        }
+        // Next field is iface.
+        int ifaceIdx = 0;
+        while (*pos != ' ' && *pos != 0 && ifaceIdx < (int)(sizeof(s.iface)-1)) {
+            s.iface[ifaceIdx] = *pos;
+            ifaceIdx++;
+            pos++;
+        }
+        if (*pos != ' ') {
+            ALOGE("bad iface: %s", buffer);
+            fclose(fp);
+            return -1;
+        }
+        s.iface[ifaceIdx] = 0;
+        if (limitIfaces.size() > 0) {
+            // Is this an iface the caller is interested in?
+            int i = 0;
+            while (i < (int)limitIfaces.size()) {
+                if (limitIfaces[i] == s.iface) {
+                    break;
+                }
+                i++;
+            }
+            if (i >= (int)limitIfaces.size()) {
+                // Nothing matched; skip this line.
+                //ALOGI("skipping due to iface: %s", buffer);
+                continue;
+            }
+        }
+
+        // Ignore whitespace
+        while (*pos == ' ') pos++;
+
+        // Find end of tag field
+        endPos = pos;
+        while (*endPos != ' ') endPos++;
+
+        // Three digit field is always 0x0, otherwise parse
+        if (endPos - pos == 3) {
+            rawTag = 0;
+        } else {
+            if (sscanf(pos, "%" PRIx64, &rawTag) != 1) {
+                ALOGE("bad tag: %s", pos);
+                fclose(fp);
+                return -1;
+            }
+        }
+        s.tag = rawTag >> 32;
+        if (limitTag != -1 && s.tag != static_cast<uint32_t>(limitTag)) {
+            //ALOGI("skipping due to tag: %s", buffer);
+            continue;
+        }
+        pos = endPos;
+
+        // Ignore whitespace
+        while (*pos == ' ') pos++;
+
+        // Parse remaining fields.
+        if (sscanf(pos, "%u %u %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64,
+                &s.uid, &s.set, &s.rxBytes, &s.rxPackets,
+                &s.txBytes, &s.txPackets) == 6) {
+            if (limitUid != -1 && static_cast<uint32_t>(limitUid) != s.uid) {
+                //ALOGI("skipping due to uid: %s", buffer);
+                continue;
+            }
+            lines->push_back(s);
+        } else {
+            //ALOGI("skipping due to bad remaining fields: %s", pos);
+        }
+    }
+
+    if (fclose(fp) != 0) {
+        ALOGE("Failed to close netstats file");
+        return -1;
+    }
+    return 0;
+}
+
+static int statsLinesToNetworkStats(JNIEnv* env, jclass clazz, jobject stats,
+                            std::vector<stats_line>& lines) {
+    int size = lines.size();
+
+    bool grow = size > env->GetIntField(stats, gNetworkStatsClassInfo.capacity);
+
+    ScopedLocalRef<jobjectArray> iface(env, get_string_array(env, stats,
+            gNetworkStatsClassInfo.iface, size, grow));
+    if (iface.get() == NULL) return -1;
+    ScopedIntArrayRW uid(env, get_int_array(env, stats,
+            gNetworkStatsClassInfo.uid, size, grow));
+    if (uid.get() == NULL) return -1;
+    ScopedIntArrayRW set(env, get_int_array(env, stats,
+            gNetworkStatsClassInfo.set, size, grow));
+    if (set.get() == NULL) return -1;
+    ScopedIntArrayRW tag(env, get_int_array(env, stats,
+            gNetworkStatsClassInfo.tag, size, grow));
+    if (tag.get() == NULL) return -1;
+    ScopedIntArrayRW metered(env, get_int_array(env, stats,
+            gNetworkStatsClassInfo.metered, size, grow));
+    if (metered.get() == NULL) return -1;
+    ScopedIntArrayRW roaming(env, get_int_array(env, stats,
+            gNetworkStatsClassInfo.roaming, size, grow));
+    if (roaming.get() == NULL) return -1;
+    ScopedIntArrayRW defaultNetwork(env, get_int_array(env, stats,
+            gNetworkStatsClassInfo.defaultNetwork, size, grow));
+    if (defaultNetwork.get() == NULL) return -1;
+    ScopedLongArrayRW rxBytes(env, get_long_array(env, stats,
+            gNetworkStatsClassInfo.rxBytes, size, grow));
+    if (rxBytes.get() == NULL) return -1;
+    ScopedLongArrayRW rxPackets(env, get_long_array(env, stats,
+            gNetworkStatsClassInfo.rxPackets, size, grow));
+    if (rxPackets.get() == NULL) return -1;
+    ScopedLongArrayRW txBytes(env, get_long_array(env, stats,
+            gNetworkStatsClassInfo.txBytes, size, grow));
+    if (txBytes.get() == NULL) return -1;
+    ScopedLongArrayRW txPackets(env, get_long_array(env, stats,
+            gNetworkStatsClassInfo.txPackets, size, grow));
+    if (txPackets.get() == NULL) return -1;
+    ScopedLongArrayRW operations(env, get_long_array(env, stats,
+            gNetworkStatsClassInfo.operations, size, grow));
+    if (operations.get() == NULL) return -1;
+
+    for (int i = 0; i < size; i++) {
+        ScopedLocalRef<jstring> ifaceString(env, env->NewStringUTF(lines[i].iface));
+        env->SetObjectArrayElement(iface.get(), i, ifaceString.get());
+
+        uid[i] = lines[i].uid;
+        set[i] = lines[i].set;
+        tag[i] = lines[i].tag;
+        // Metered, roaming and defaultNetwork are populated in Java-land.
+        rxBytes[i] = lines[i].rxBytes;
+        rxPackets[i] = lines[i].rxPackets;
+        txBytes[i] = lines[i].txBytes;
+        txPackets[i] = lines[i].txPackets;
+    }
+
+    env->SetIntField(stats, gNetworkStatsClassInfo.size, size);
+    if (grow) {
+        env->SetIntField(stats, gNetworkStatsClassInfo.capacity, size);
+        env->SetObjectField(stats, gNetworkStatsClassInfo.iface, iface.get());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.uid, uid.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.set, set.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.tag, tag.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.metered, metered.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.roaming, roaming.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.defaultNetwork,
+                defaultNetwork.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.rxBytes, rxBytes.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.rxPackets, rxPackets.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.txBytes, txBytes.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.txPackets, txPackets.getJavaArray());
+        env->SetObjectField(stats, gNetworkStatsClassInfo.operations, operations.getJavaArray());
+    }
+    return 0;
+}
+
+static int readNetworkStatsDetail(JNIEnv* env, jclass clazz, jobject stats, jstring path,
+                                  jint limitUid, jobjectArray limitIfacesObj, jint limitTag,
+                                  jboolean useBpfStats) {
+
+    std::vector<std::string> limitIfaces;
+    if (limitIfacesObj != NULL && env->GetArrayLength(limitIfacesObj) > 0) {
+        int num = env->GetArrayLength(limitIfacesObj);
+        for (int i = 0; i < num; i++) {
+            jstring string = (jstring)env->GetObjectArrayElement(limitIfacesObj, i);
+            ScopedUtfChars string8(env, string);
+            if (string8.c_str() != NULL) {
+                limitIfaces.push_back(std::string(string8.c_str()));
+            }
+        }
+    }
+    std::vector<stats_line> lines;
+
+
+    if (useBpfStats) {
+        if (parseBpfNetworkStatsDetail(&lines, limitIfaces, limitTag, limitUid) < 0)
+            return -1;
+    } else {
+        ScopedUtfChars path8(env, path);
+        if (path8.c_str() == NULL) {
+            ALOGE("the qtaguid legacy path is invalid: %s", path8.c_str());
+            return -1;
+        }
+        if (legacyReadNetworkStatsDetail(&lines, limitIfaces, limitTag,
+                                         limitUid, path8.c_str()) < 0)
+            return -1;
+    }
+
+    return statsLinesToNetworkStats(env, clazz, stats, lines);
+}
+
+static int readNetworkStatsDev(JNIEnv* env, jclass clazz, jobject stats) {
+    std::vector<stats_line> lines;
+
+    if (parseBpfNetworkStatsDev(&lines) < 0)
+            return -1;
+
+    return statsLinesToNetworkStats(env, clazz, stats, lines);
+}
+
+static const JNINativeMethod gMethods[] = {
+        { "nativeReadNetworkStatsDetail",
+                "(Landroid/net/NetworkStats;Ljava/lang/String;I[Ljava/lang/String;IZ)I",
+                (void*) readNetworkStatsDetail },
+        { "nativeReadNetworkStatsDev", "(Landroid/net/NetworkStats;)I",
+                (void*) readNetworkStatsDev },
+};
+
+int register_android_server_net_NetworkStatsFactory(JNIEnv* env) {
+    int err = jniRegisterNativeMethods(env, "com/android/server/net/NetworkStatsFactory", gMethods,
+            NELEM(gMethods));
+    gStringClass = env->FindClass("java/lang/String");
+    gStringClass = static_cast<jclass>(env->NewGlobalRef(gStringClass));
+
+    jclass clazz = env->FindClass("android/net/NetworkStats");
+    gNetworkStatsClassInfo.size = env->GetFieldID(clazz, "size", "I");
+    gNetworkStatsClassInfo.capacity = env->GetFieldID(clazz, "capacity", "I");
+    gNetworkStatsClassInfo.iface = env->GetFieldID(clazz, "iface", "[Ljava/lang/String;");
+    gNetworkStatsClassInfo.uid = env->GetFieldID(clazz, "uid", "[I");
+    gNetworkStatsClassInfo.set = env->GetFieldID(clazz, "set", "[I");
+    gNetworkStatsClassInfo.tag = env->GetFieldID(clazz, "tag", "[I");
+    gNetworkStatsClassInfo.metered = env->GetFieldID(clazz, "metered", "[I");
+    gNetworkStatsClassInfo.roaming = env->GetFieldID(clazz, "roaming", "[I");
+    gNetworkStatsClassInfo.defaultNetwork = env->GetFieldID(clazz, "defaultNetwork", "[I");
+    gNetworkStatsClassInfo.rxBytes = env->GetFieldID(clazz, "rxBytes", "[J");
+    gNetworkStatsClassInfo.rxPackets = env->GetFieldID(clazz, "rxPackets", "[J");
+    gNetworkStatsClassInfo.txBytes = env->GetFieldID(clazz, "txBytes", "[J");
+    gNetworkStatsClassInfo.txPackets = env->GetFieldID(clazz, "txPackets", "[J");
+    gNetworkStatsClassInfo.operations = env->GetFieldID(clazz, "operations", "[J");
+
+    return err;
+}
+
+}
diff --git a/service-t/jni/com_android_server_net_NetworkStatsService.cpp b/service-t/jni/com_android_server_net_NetworkStatsService.cpp
new file mode 100644
index 0000000..39cbaf7
--- /dev/null
+++ b/service-t/jni/com_android_server_net_NetworkStatsService.cpp
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2010 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 "NetworkStatsNative"
+
+#include <cutils/qtaguid.h>
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <jni.h>
+#include <nativehelper/ScopedUtfChars.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <utils/Log.h>
+#include <utils/misc.h>
+
+#include "bpf/BpfUtils.h"
+#include "netdbpf/BpfNetworkStats.h"
+
+using android::bpf::bpfGetUidStats;
+using android::bpf::bpfGetIfaceStats;
+
+namespace android {
+
+// NOTE: keep these in sync with TrafficStats.java
+static const uint64_t UNKNOWN = -1;
+
+enum StatsType {
+    RX_BYTES = 0,
+    RX_PACKETS = 1,
+    TX_BYTES = 2,
+    TX_PACKETS = 3,
+    TCP_RX_PACKETS = 4,
+    TCP_TX_PACKETS = 5
+};
+
+static uint64_t getStatsType(Stats* stats, StatsType type) {
+    switch (type) {
+        case RX_BYTES:
+            return stats->rxBytes;
+        case RX_PACKETS:
+            return stats->rxPackets;
+        case TX_BYTES:
+            return stats->txBytes;
+        case TX_PACKETS:
+            return stats->txPackets;
+        case TCP_RX_PACKETS:
+            return stats->tcpRxPackets;
+        case TCP_TX_PACKETS:
+            return stats->tcpTxPackets;
+        default:
+            return UNKNOWN;
+    }
+}
+
+static jlong getTotalStat(JNIEnv* env, jclass clazz, jint type) {
+    Stats stats = {};
+
+    if (bpfGetIfaceStats(NULL, &stats) == 0) {
+        return getStatsType(&stats, (StatsType) type);
+    } else {
+        return UNKNOWN;
+    }
+}
+
+static jlong getIfaceStat(JNIEnv* env, jclass clazz, jstring iface, jint type) {
+    ScopedUtfChars iface8(env, iface);
+    if (iface8.c_str() == NULL) {
+        return UNKNOWN;
+    }
+
+    Stats stats = {};
+
+    if (bpfGetIfaceStats(iface8.c_str(), &stats) == 0) {
+        return getStatsType(&stats, (StatsType) type);
+    } else {
+        return UNKNOWN;
+    }
+}
+
+static jlong getUidStat(JNIEnv* env, jclass clazz, jint uid, jint type) {
+    Stats stats = {};
+
+    if (bpfGetUidStats(uid, &stats) == 0) {
+        return getStatsType(&stats, (StatsType) type);
+    } else {
+        return UNKNOWN;
+    }
+}
+
+static const JNINativeMethod gMethods[] = {
+        {"nativeGetTotalStat", "(I)J", (void*)getTotalStat},
+        {"nativeGetIfaceStat", "(Ljava/lang/String;I)J", (void*)getIfaceStat},
+        {"nativeGetUidStat", "(II)J", (void*)getUidStat},
+};
+
+int register_android_server_net_NetworkStatsService(JNIEnv* env) {
+    return jniRegisterNativeMethods(env, "com/android/server/net/NetworkStatsService", gMethods,
+                                    NELEM(gMethods));
+}
+
+}
diff --git a/service-t/jni/onload.cpp b/service-t/jni/onload.cpp
new file mode 100644
index 0000000..bca4697
--- /dev/null
+++ b/service-t/jni/onload.cpp
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+#include <nativehelper/JNIHelp.h>
+#include <log/log.h>
+
+namespace android {
+
+int register_com_android_net_module_util_BpfMap(JNIEnv* env, char const* class_name);
+
+extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
+    JNIEnv *env;
+    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
+        ALOGE("GetEnv failed");
+        return JNI_ERR;
+    }
+
+    if (register_com_android_net_module_util_BpfMap(env,
+            "com/android/net/module/util/BpfMap") < 0) return JNI_ERR;
+
+    return JNI_VERSION_1_6;
+}
+
+};
+
diff --git a/service-t/src/com/android/server/INativeDaemonConnectorCallbacks.java b/service-t/src/com/android/server/INativeDaemonConnectorCallbacks.java
new file mode 100644
index 0000000..0cf9dcd
--- /dev/null
+++ b/service-t/src/com/android/server/INativeDaemonConnectorCallbacks.java
@@ -0,0 +1,25 @@
+
+/*
+ * Copyright (C) 2007 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;
+
+interface INativeDaemonConnectorCallbacks {
+
+    void onDaemonConnected();
+    boolean onCheckHoldWakeLock(int code);
+    boolean onEvent(int code, String raw, String[] cooked);
+}
diff --git a/service-t/src/com/android/server/IpSecService.java b/service-t/src/com/android/server/IpSecService.java
new file mode 100644
index 0000000..4bc40ea
--- /dev/null
+++ b/service-t/src/com/android/server/IpSecService.java
@@ -0,0 +1,1878 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server;
+
+import static android.Manifest.permission.DUMP;
+import static android.net.IpSecManager.INVALID_RESOURCE_ID;
+import static android.system.OsConstants.AF_INET;
+import static android.system.OsConstants.AF_INET6;
+import static android.system.OsConstants.AF_UNSPEC;
+import static android.system.OsConstants.EINVAL;
+import static android.system.OsConstants.IPPROTO_UDP;
+import static android.system.OsConstants.SOCK_DGRAM;
+
+import android.annotation.NonNull;
+import android.app.AppOpsManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
+import android.net.IIpSecService;
+import android.net.INetd;
+import android.net.InetAddresses;
+import android.net.IpSecAlgorithm;
+import android.net.IpSecConfig;
+import android.net.IpSecManager;
+import android.net.IpSecSpiResponse;
+import android.net.IpSecTransform;
+import android.net.IpSecTransformResponse;
+import android.net.IpSecTunnelInterfaceResponse;
+import android.net.IpSecUdpEncapResponse;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.TrafficStats;
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.os.Process;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.Range;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.Preconditions;
+import com.android.net.module.util.BinderUtils;
+import com.android.net.module.util.NetdUtils;
+import com.android.net.module.util.PermissionUtils;
+
+import libcore.io.IoUtils;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A service to manage multiple clients that want to access the IpSec API. The service is
+ * responsible for maintaining a list of clients and managing the resources (and related quotas)
+ * that each of them own.
+ *
+ * <p>Synchronization in IpSecService is done on all entrypoints due to potential race conditions at
+ * the kernel/xfrm level. Further, this allows the simplifying assumption to be made that only one
+ * thread is ever running at a time.
+ *
+ * @hide
+ */
+public class IpSecService extends IIpSecService.Stub {
+    private static final String TAG = "IpSecService";
+    private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+    private static final int[] ADDRESS_FAMILIES =
+            new int[] {OsConstants.AF_INET, OsConstants.AF_INET6};
+
+    private static final int NETD_FETCH_TIMEOUT_MS = 5000; // ms
+    private static final InetAddress INADDR_ANY;
+
+    @VisibleForTesting static final int MAX_PORT_BIND_ATTEMPTS = 10;
+
+    private final INetd mNetd;
+
+    static {
+        try {
+            INADDR_ANY = InetAddress.getByAddress(new byte[] {0, 0, 0, 0});
+        } catch (UnknownHostException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    static final int FREE_PORT_MIN = 1024; // ports 1-1023 are reserved
+    static final int PORT_MAX = 0xFFFF; // ports are an unsigned 16-bit integer
+
+    /* Binder context for this service */
+    private final Context mContext;
+    private final Dependencies mDeps;
+
+    /**
+     * The next non-repeating global ID for tracking resources between users, this service, and
+     * kernel data structures. Accessing this variable is not thread safe, so it is only read or
+     * modified within blocks synchronized on IpSecService.this. We want to avoid -1
+     * (INVALID_RESOURCE_ID) and 0 (we probably forgot to initialize it).
+     */
+    @GuardedBy("IpSecService.this")
+    private int mNextResourceId = 1;
+
+    /**
+     * Dependencies of IpSecService, for injection in tests.
+     */
+    @VisibleForTesting
+    public static class Dependencies {
+        /**
+         * Get a reference to INetd.
+         */
+        public INetd getNetdInstance(Context context) throws RemoteException {
+            final INetd netd = INetd.Stub.asInterface((IBinder)
+                    context.getSystemService(Context.NETD_SERVICE));
+            if (netd == null) {
+                throw new RemoteException("Failed to Get Netd Instance");
+            }
+            return netd;
+        }
+    }
+
+    final UidFdTagger mUidFdTagger;
+
+    /**
+     * Interface for user-reference and kernel-resource cleanup.
+     *
+     * <p>This interface must be implemented for a resource to be reference counted.
+     */
+    @VisibleForTesting
+    public interface IResource {
+        /**
+         * Invalidates a IResource object, ensuring it is invalid for the purposes of allocating new
+         * objects dependent on it.
+         *
+         * <p>Implementations of this method are expected to remove references to the IResource
+         * object from the IpSecService's tracking arrays. The removal from the arrays ensures that
+         * the resource is considered invalid for user access or allocation or use in other
+         * resources.
+         *
+         * <p>References to the IResource object may be held by other RefcountedResource objects,
+         * and as such, the underlying resources and quota may not be cleaned up.
+         */
+        void invalidate() throws RemoteException;
+
+        /**
+         * Releases underlying resources and related quotas.
+         *
+         * <p>Implementations of this method are expected to remove all system resources that are
+         * tracked by the IResource object. Due to other RefcountedResource objects potentially
+         * having references to the IResource object, freeUnderlyingResources may not always be
+         * called from releaseIfUnreferencedRecursively().
+         */
+        void freeUnderlyingResources() throws RemoteException;
+    }
+
+    /**
+     * RefcountedResource manages references and dependencies in an exclusively acyclic graph.
+     *
+     * <p>RefcountedResource implements both explicit and implicit resource management. Creating a
+     * RefcountedResource object creates an explicit reference that must be freed by calling
+     * userRelease(). Additionally, adding this object as a child of another RefcountedResource
+     * object will add an implicit reference.
+     *
+     * <p>Resources are cleaned up when all references, both implicit and explicit, are released
+     * (ie, when userRelease() is called and when all parents have called releaseReference() on this
+     * object.)
+     */
+    @VisibleForTesting
+    public class RefcountedResource<T extends IResource> implements IBinder.DeathRecipient {
+        private final T mResource;
+        private final List<RefcountedResource> mChildren;
+        int mRefCount = 1; // starts at 1 for user's reference.
+        IBinder mBinder;
+
+        RefcountedResource(T resource, IBinder binder, RefcountedResource... children) {
+            synchronized (IpSecService.this) {
+                this.mResource = resource;
+                this.mChildren = new ArrayList<>(children.length);
+                this.mBinder = binder;
+
+                for (RefcountedResource child : children) {
+                    mChildren.add(child);
+                    child.mRefCount++;
+                }
+
+                try {
+                    mBinder.linkToDeath(this, 0);
+                } catch (RemoteException e) {
+                    binderDied();
+                    e.rethrowFromSystemServer();
+                }
+            }
+        }
+
+        /**
+         * If the Binder object dies, this function is called to free the system resources that are
+         * being tracked by this record and to subsequently release this record for garbage
+         * collection
+         */
+        @Override
+        public void binderDied() {
+            synchronized (IpSecService.this) {
+                try {
+                    userRelease();
+                } catch (Exception e) {
+                    Log.e(TAG, "Failed to release resource: " + e);
+                }
+            }
+        }
+
+        public T getResource() {
+            return mResource;
+        }
+
+        /**
+         * Unlinks from binder and performs IpSecService resource cleanup (removes from resource
+         * arrays)
+         *
+         * <p>If this method has been previously called, the RefcountedResource's binder field will
+         * be null, and the method will return without performing the cleanup a second time.
+         *
+         * <p>Note that calling this function does not imply that kernel resources will be freed at
+         * this time, or that the related quota will be returned. Such actions will only be
+         * performed upon the reference count reaching zero.
+         */
+        @GuardedBy("IpSecService.this")
+        public void userRelease() throws RemoteException {
+            // Prevent users from putting reference counts into a bad state by calling
+            // userRelease() multiple times.
+            if (mBinder == null) {
+                return;
+            }
+
+            mBinder.unlinkToDeath(this, 0);
+            mBinder = null;
+
+            mResource.invalidate();
+
+            releaseReference();
+        }
+
+        /**
+         * Removes a reference to this resource. If the resultant reference count is zero, the
+         * underlying resources are freed, and references to all child resources are also dropped
+         * recursively (resulting in them freeing their resources and children, etcetera)
+         *
+         * <p>This method also sets the reference count to an invalid value (-1) to signify that it
+         * has been fully released. Any subsequent calls to this method will result in an
+         * IllegalStateException being thrown due to resource already having been previously
+         * released
+         */
+        @VisibleForTesting
+        @GuardedBy("IpSecService.this")
+        public void releaseReference() throws RemoteException {
+            mRefCount--;
+
+            if (mRefCount > 0) {
+                return;
+            } else if (mRefCount < 0) {
+                throw new IllegalStateException(
+                        "Invalid operation - resource has already been released.");
+            }
+
+            // Cleanup own resources
+            mResource.freeUnderlyingResources();
+
+            // Cleanup child resources as needed
+            for (RefcountedResource<? extends IResource> child : mChildren) {
+                child.releaseReference();
+            }
+
+            // Enforce that resource cleanup can only be called once
+            // By decrementing the refcount (from 0 to -1), the next call will throw an
+            // IllegalStateException - it has already been released fully.
+            mRefCount--;
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                    .append("{mResource=")
+                    .append(mResource)
+                    .append(", mRefCount=")
+                    .append(mRefCount)
+                    .append(", mChildren=")
+                    .append(mChildren)
+                    .append("}")
+                    .toString();
+        }
+    }
+
+    /**
+     * Very simple counting class that looks much like a counting semaphore
+     *
+     * <p>This class is not thread-safe, and expects that that users of this class will ensure
+     * synchronization and thread safety by holding the IpSecService.this instance lock.
+     */
+    @VisibleForTesting
+    static class ResourceTracker {
+        private final int mMax;
+        int mCurrent;
+
+        ResourceTracker(int max) {
+            mMax = max;
+            mCurrent = 0;
+        }
+
+        boolean isAvailable() {
+            return (mCurrent < mMax);
+        }
+
+        void take() {
+            if (!isAvailable()) {
+                Log.wtf(TAG, "Too many resources allocated!");
+            }
+            mCurrent++;
+        }
+
+        void give() {
+            if (mCurrent <= 0) {
+                Log.wtf(TAG, "We've released this resource too many times");
+            }
+            mCurrent--;
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                    .append("{mCurrent=")
+                    .append(mCurrent)
+                    .append(", mMax=")
+                    .append(mMax)
+                    .append("}")
+                    .toString();
+        }
+    }
+
+    @VisibleForTesting
+    static final class UserRecord {
+        /* Maximum number of each type of resource that a single UID may possess */
+
+        // Up to 4 active VPNs/IWLAN with potential soft handover.
+        public static final int MAX_NUM_TUNNEL_INTERFACES = 8;
+        public static final int MAX_NUM_ENCAP_SOCKETS = 16;
+
+        // SPIs and Transforms are both cheap, and are 1:1 correlated.
+        public static final int MAX_NUM_TRANSFORMS = 64;
+        public static final int MAX_NUM_SPIS = 64;
+
+        /**
+         * Store each of the OwnedResource types in an (thinly wrapped) sparse array for indexing
+         * and explicit (user) reference management.
+         *
+         * <p>These are stored in separate arrays to improve debuggability and dump output clarity.
+         *
+         * <p>Resources are removed from this array when the user releases their explicit reference
+         * by calling one of the releaseResource() methods.
+         */
+        final RefcountedResourceArray<SpiRecord> mSpiRecords =
+                new RefcountedResourceArray<>(SpiRecord.class.getSimpleName());
+        final RefcountedResourceArray<TransformRecord> mTransformRecords =
+                new RefcountedResourceArray<>(TransformRecord.class.getSimpleName());
+        final RefcountedResourceArray<EncapSocketRecord> mEncapSocketRecords =
+                new RefcountedResourceArray<>(EncapSocketRecord.class.getSimpleName());
+        final RefcountedResourceArray<TunnelInterfaceRecord> mTunnelInterfaceRecords =
+                new RefcountedResourceArray<>(TunnelInterfaceRecord.class.getSimpleName());
+
+        /**
+         * Trackers for quotas for each of the OwnedResource types.
+         *
+         * <p>These trackers are separate from the resource arrays, since they are incremented and
+         * decremented at different points in time. Specifically, quota is only returned upon final
+         * resource deallocation (after all explicit and implicit references are released). Note
+         * that it is possible that calls to releaseResource() will not return the used quota if
+         * there are other resources that depend on (are parents of) the resource being released.
+         */
+        final ResourceTracker mSpiQuotaTracker = new ResourceTracker(MAX_NUM_SPIS);
+        final ResourceTracker mTransformQuotaTracker = new ResourceTracker(MAX_NUM_TRANSFORMS);
+        final ResourceTracker mSocketQuotaTracker = new ResourceTracker(MAX_NUM_ENCAP_SOCKETS);
+        final ResourceTracker mTunnelQuotaTracker = new ResourceTracker(MAX_NUM_TUNNEL_INTERFACES);
+
+        void removeSpiRecord(int resourceId) {
+            mSpiRecords.remove(resourceId);
+        }
+
+        void removeTransformRecord(int resourceId) {
+            mTransformRecords.remove(resourceId);
+        }
+
+        void removeTunnelInterfaceRecord(int resourceId) {
+            mTunnelInterfaceRecords.remove(resourceId);
+        }
+
+        void removeEncapSocketRecord(int resourceId) {
+            mEncapSocketRecords.remove(resourceId);
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                    .append("{mSpiQuotaTracker=")
+                    .append(mSpiQuotaTracker)
+                    .append(", mTransformQuotaTracker=")
+                    .append(mTransformQuotaTracker)
+                    .append(", mSocketQuotaTracker=")
+                    .append(mSocketQuotaTracker)
+                    .append(", mTunnelQuotaTracker=")
+                    .append(mTunnelQuotaTracker)
+                    .append(", mSpiRecords=")
+                    .append(mSpiRecords)
+                    .append(", mTransformRecords=")
+                    .append(mTransformRecords)
+                    .append(", mEncapSocketRecords=")
+                    .append(mEncapSocketRecords)
+                    .append(", mTunnelInterfaceRecords=")
+                    .append(mTunnelInterfaceRecords)
+                    .append("}")
+                    .toString();
+        }
+    }
+
+    /**
+     * This class is not thread-safe, and expects that that users of this class will ensure
+     * synchronization and thread safety by holding the IpSecService.this instance lock.
+     */
+    @VisibleForTesting
+    static final class UserResourceTracker {
+        private final SparseArray<UserRecord> mUserRecords = new SparseArray<>();
+
+        /** Lazy-initialization/getter that populates or retrieves the UserRecord as needed */
+        public UserRecord getUserRecord(int uid) {
+            checkCallerUid(uid);
+
+            UserRecord r = mUserRecords.get(uid);
+            if (r == null) {
+                r = new UserRecord();
+                mUserRecords.put(uid, r);
+            }
+            return r;
+        }
+
+        /** Safety method; guards against access of other user's UserRecords */
+        private void checkCallerUid(int uid) {
+            if (uid != Binder.getCallingUid() && Process.SYSTEM_UID != Binder.getCallingUid()) {
+                throw new SecurityException("Attempted access of unowned resources");
+            }
+        }
+
+        @Override
+        public String toString() {
+            return mUserRecords.toString();
+        }
+    }
+
+    @VisibleForTesting final UserResourceTracker mUserResourceTracker = new UserResourceTracker();
+
+    /**
+     * The OwnedResourceRecord class provides a facility to cleanly and reliably track system
+     * resources. It relies on a provided resourceId that should uniquely identify the kernel
+     * resource. To use this class, the user should implement the invalidate() and
+     * freeUnderlyingResources() methods that are responsible for cleaning up IpSecService resource
+     * tracking arrays and kernel resources, respectively.
+     *
+     * <p>This class associates kernel resources with the UID that owns and controls them.
+     */
+    private abstract class OwnedResourceRecord implements IResource {
+        final int mPid;
+        final int mUid;
+        protected final int mResourceId;
+
+        OwnedResourceRecord(int resourceId) {
+            super();
+            if (resourceId == INVALID_RESOURCE_ID) {
+                throw new IllegalArgumentException("Resource ID must not be INVALID_RESOURCE_ID");
+            }
+            mResourceId = resourceId;
+            mPid = Binder.getCallingPid();
+            mUid = Binder.getCallingUid();
+
+            getResourceTracker().take();
+        }
+
+        @Override
+        public abstract void invalidate() throws RemoteException;
+
+        /** Convenience method; retrieves the user resource record for the stored UID. */
+        protected UserRecord getUserRecord() {
+            return mUserResourceTracker.getUserRecord(mUid);
+        }
+
+        @Override
+        public abstract void freeUnderlyingResources() throws RemoteException;
+
+        /** Get the resource tracker for this resource */
+        protected abstract ResourceTracker getResourceTracker();
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                    .append("{mResourceId=")
+                    .append(mResourceId)
+                    .append(", pid=")
+                    .append(mPid)
+                    .append(", uid=")
+                    .append(mUid)
+                    .append("}")
+                    .toString();
+        }
+    };
+
+    /**
+     * Thin wrapper over SparseArray to ensure resources exist, and simplify generic typing.
+     *
+     * <p>RefcountedResourceArray prevents null insertions, and throws an IllegalArgumentException
+     * if a key is not found during a retrieval process.
+     */
+    static class RefcountedResourceArray<T extends IResource> {
+        SparseArray<RefcountedResource<T>> mArray = new SparseArray<>();
+        private final String mTypeName;
+
+        RefcountedResourceArray(String typeName) {
+            this.mTypeName = typeName;
+        }
+
+        /**
+         * Accessor method to get inner resource object.
+         *
+         * @throws IllegalArgumentException if no resource with provided key is found.
+         */
+        T getResourceOrThrow(int key) {
+            return getRefcountedResourceOrThrow(key).getResource();
+        }
+
+        /**
+         * Accessor method to get reference counting wrapper.
+         *
+         * @throws IllegalArgumentException if no resource with provided key is found.
+         */
+        RefcountedResource<T> getRefcountedResourceOrThrow(int key) {
+            RefcountedResource<T> resource = mArray.get(key);
+            if (resource == null) {
+                throw new IllegalArgumentException(
+                        String.format("No such %s found for given id: %d", mTypeName, key));
+            }
+
+            return resource;
+        }
+
+        void put(int key, RefcountedResource<T> obj) {
+            Objects.requireNonNull(obj, "Null resources cannot be added");
+            mArray.put(key, obj);
+        }
+
+        void remove(int key) {
+            mArray.remove(key);
+        }
+
+        @Override
+        public String toString() {
+            return mArray.toString();
+        }
+    }
+
+    /**
+     * Tracks an SA in the kernel, and manages cleanup paths. Once a TransformRecord is
+     * created, the SpiRecord that originally tracked the SAs will reliquish the
+     * responsibility of freeing the underlying SA to this class via the mOwnedByTransform flag.
+     */
+    private final class TransformRecord extends OwnedResourceRecord {
+        private final IpSecConfig mConfig;
+        private final SpiRecord mSpi;
+        private final EncapSocketRecord mSocket;
+
+        TransformRecord(
+                int resourceId, IpSecConfig config, SpiRecord spi, EncapSocketRecord socket) {
+            super(resourceId);
+            mConfig = config;
+            mSpi = spi;
+            mSocket = socket;
+
+            spi.setOwnedByTransform();
+        }
+
+        public IpSecConfig getConfig() {
+            return mConfig;
+        }
+
+        public SpiRecord getSpiRecord() {
+            return mSpi;
+        }
+
+        public EncapSocketRecord getSocketRecord() {
+            return mSocket;
+        }
+
+        /** always guarded by IpSecService#this */
+        @Override
+        public void freeUnderlyingResources() {
+            int spi = mSpi.getSpi();
+            try {
+                mNetd.ipSecDeleteSecurityAssociation(
+                        mUid,
+                        mConfig.getSourceAddress(),
+                        mConfig.getDestinationAddress(),
+                        spi,
+                        mConfig.getMarkValue(),
+                        mConfig.getMarkMask(),
+                        mConfig.getXfrmInterfaceId());
+            } catch (RemoteException | ServiceSpecificException e) {
+                Log.e(TAG, "Failed to delete SA with ID: " + mResourceId, e);
+            }
+
+            getResourceTracker().give();
+        }
+
+        @Override
+        public void invalidate() throws RemoteException {
+            getUserRecord().removeTransformRecord(mResourceId);
+        }
+
+        @Override
+        protected ResourceTracker getResourceTracker() {
+            return getUserRecord().mTransformQuotaTracker;
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder strBuilder = new StringBuilder();
+            strBuilder
+                    .append("{super=")
+                    .append(super.toString())
+                    .append(", mSocket=")
+                    .append(mSocket)
+                    .append(", mSpi.mResourceId=")
+                    .append(mSpi.mResourceId)
+                    .append(", mConfig=")
+                    .append(mConfig)
+                    .append("}");
+            return strBuilder.toString();
+        }
+    }
+
+    /**
+     * Tracks a single SA in the kernel, and manages cleanup paths. Once used in a Transform, the
+     * responsibility for cleaning up underlying resources will be passed to the TransformRecord
+     * object
+     */
+    private final class SpiRecord extends OwnedResourceRecord {
+        private final String mSourceAddress;
+        private final String mDestinationAddress;
+        private int mSpi;
+
+        private boolean mOwnedByTransform = false;
+
+        SpiRecord(int resourceId, String sourceAddress,
+                String destinationAddress, int spi) {
+            super(resourceId);
+            mSourceAddress = sourceAddress;
+            mDestinationAddress = destinationAddress;
+            mSpi = spi;
+        }
+
+        /** always guarded by IpSecService#this */
+        @Override
+        public void freeUnderlyingResources() {
+            try {
+                if (!mOwnedByTransform) {
+                    mNetd.ipSecDeleteSecurityAssociation(
+                            mUid, mSourceAddress, mDestinationAddress, mSpi, 0 /* mark */,
+                            0 /* mask */, 0 /* if_id */);
+                }
+            } catch (ServiceSpecificException | RemoteException e) {
+                Log.e(TAG, "Failed to delete SPI reservation with ID: " + mResourceId, e);
+            }
+
+            mSpi = IpSecManager.INVALID_SECURITY_PARAMETER_INDEX;
+
+            getResourceTracker().give();
+        }
+
+        public int getSpi() {
+            return mSpi;
+        }
+
+        public String getDestinationAddress() {
+            return mDestinationAddress;
+        }
+
+        public void setOwnedByTransform() {
+            if (mOwnedByTransform) {
+                // Programming error
+                throw new IllegalStateException("Cannot own an SPI twice!");
+            }
+
+            mOwnedByTransform = true;
+        }
+
+        public boolean getOwnedByTransform() {
+            return mOwnedByTransform;
+        }
+
+        @Override
+        public void invalidate() throws RemoteException {
+            getUserRecord().removeSpiRecord(mResourceId);
+        }
+
+        @Override
+        protected ResourceTracker getResourceTracker() {
+            return getUserRecord().mSpiQuotaTracker;
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder strBuilder = new StringBuilder();
+            strBuilder
+                    .append("{super=")
+                    .append(super.toString())
+                    .append(", mSpi=")
+                    .append(mSpi)
+                    .append(", mSourceAddress=")
+                    .append(mSourceAddress)
+                    .append(", mDestinationAddress=")
+                    .append(mDestinationAddress)
+                    .append(", mOwnedByTransform=")
+                    .append(mOwnedByTransform)
+                    .append("}");
+            return strBuilder.toString();
+        }
+    }
+
+    private final SparseBooleanArray mTunnelNetIds = new SparseBooleanArray();
+    final Range<Integer> mNetIdRange = ConnectivityManager.getIpSecNetIdRange();
+    private int mNextTunnelNetId = mNetIdRange.getLower();
+
+    /**
+     * Reserves a netId within the range of netIds allocated for IPsec tunnel interfaces
+     *
+     * <p>This method should only be called from Binder threads. Do not call this from within the
+     * system server as it will crash the system on failure.
+     *
+     * @return an integer key within the netId range, if successful
+     * @throws IllegalStateException if unsuccessful (all netId are currently reserved)
+     */
+    @VisibleForTesting
+    int reserveNetId() {
+        final int range = mNetIdRange.getUpper() - mNetIdRange.getLower() + 1;
+        synchronized (mTunnelNetIds) {
+            for (int i = 0; i < range; i++) {
+                final int netId = mNextTunnelNetId;
+                if (++mNextTunnelNetId > mNetIdRange.getUpper()) {
+                    mNextTunnelNetId = mNetIdRange.getLower();
+                }
+                if (!mTunnelNetIds.get(netId)) {
+                    mTunnelNetIds.put(netId, true);
+                    return netId;
+                }
+            }
+        }
+        throw new IllegalStateException("No free netIds to allocate");
+    }
+
+    @VisibleForTesting
+    void releaseNetId(int netId) {
+        synchronized (mTunnelNetIds) {
+            mTunnelNetIds.delete(netId);
+        }
+    }
+
+    /**
+     * Tracks an tunnel interface, and manages cleanup paths.
+     *
+     * <p>This class is not thread-safe, and expects that that users of this class will ensure
+     * synchronization and thread safety by holding the IpSecService.this instance lock
+     */
+    @VisibleForTesting
+    final class TunnelInterfaceRecord extends OwnedResourceRecord {
+        private final String mInterfaceName;
+
+        // outer addresses
+        private final String mLocalAddress;
+        private final String mRemoteAddress;
+
+        private final int mIkey;
+        private final int mOkey;
+
+        private final int mIfId;
+
+        private Network mUnderlyingNetwork;
+
+        TunnelInterfaceRecord(
+                int resourceId,
+                String interfaceName,
+                Network underlyingNetwork,
+                String localAddr,
+                String remoteAddr,
+                int ikey,
+                int okey,
+                int intfId) {
+            super(resourceId);
+
+            mInterfaceName = interfaceName;
+            mUnderlyingNetwork = underlyingNetwork;
+            mLocalAddress = localAddr;
+            mRemoteAddress = remoteAddr;
+            mIkey = ikey;
+            mOkey = okey;
+            mIfId = intfId;
+        }
+
+        /** always guarded by IpSecService#this */
+        @Override
+        public void freeUnderlyingResources() {
+            // Calls to netd
+            //       Teardown VTI
+            //       Delete global policies
+            try {
+                mNetd.ipSecRemoveTunnelInterface(mInterfaceName);
+
+                for (int selAddrFamily : ADDRESS_FAMILIES) {
+                    mNetd.ipSecDeleteSecurityPolicy(
+                            mUid,
+                            selAddrFamily,
+                            IpSecManager.DIRECTION_OUT,
+                            mOkey,
+                            0xffffffff,
+                            mIfId);
+                    mNetd.ipSecDeleteSecurityPolicy(
+                            mUid,
+                            selAddrFamily,
+                            IpSecManager.DIRECTION_IN,
+                            mIkey,
+                            0xffffffff,
+                            mIfId);
+                }
+            } catch (ServiceSpecificException | RemoteException e) {
+                Log.e(
+                        TAG,
+                        "Failed to delete VTI with interface name: "
+                                + mInterfaceName
+                                + " and id: "
+                                + mResourceId, e);
+            }
+
+            getResourceTracker().give();
+            releaseNetId(mIkey);
+            releaseNetId(mOkey);
+        }
+
+        @GuardedBy("IpSecService.this")
+        public void setUnderlyingNetwork(Network underlyingNetwork) {
+            // When #applyTunnelModeTransform is called, this new underlying network will be used to
+            // update the output mark of the input transform.
+            mUnderlyingNetwork = underlyingNetwork;
+        }
+
+        @GuardedBy("IpSecService.this")
+        public Network getUnderlyingNetwork() {
+            return mUnderlyingNetwork;
+        }
+
+        public String getInterfaceName() {
+            return mInterfaceName;
+        }
+
+        /** Returns the local, outer address for the tunnelInterface */
+        public String getLocalAddress() {
+            return mLocalAddress;
+        }
+
+        /** Returns the remote, outer address for the tunnelInterface */
+        public String getRemoteAddress() {
+            return mRemoteAddress;
+        }
+
+        public int getIkey() {
+            return mIkey;
+        }
+
+        public int getOkey() {
+            return mOkey;
+        }
+
+        public int getIfId() {
+            return mIfId;
+        }
+
+        @Override
+        protected ResourceTracker getResourceTracker() {
+            return getUserRecord().mTunnelQuotaTracker;
+        }
+
+        @Override
+        public void invalidate() {
+            getUserRecord().removeTunnelInterfaceRecord(mResourceId);
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                    .append("{super=")
+                    .append(super.toString())
+                    .append(", mInterfaceName=")
+                    .append(mInterfaceName)
+                    .append(", mUnderlyingNetwork=")
+                    .append(mUnderlyingNetwork)
+                    .append(", mLocalAddress=")
+                    .append(mLocalAddress)
+                    .append(", mRemoteAddress=")
+                    .append(mRemoteAddress)
+                    .append(", mIkey=")
+                    .append(mIkey)
+                    .append(", mOkey=")
+                    .append(mOkey)
+                    .append("}")
+                    .toString();
+        }
+    }
+
+    /**
+     * Tracks a UDP encap socket, and manages cleanup paths
+     *
+     * <p>While this class does not manage non-kernel resources, race conditions around socket
+     * binding require that the service creates the encap socket, binds it and applies the socket
+     * policy before handing it to a user.
+     */
+    private final class EncapSocketRecord extends OwnedResourceRecord {
+        private FileDescriptor mSocket;
+        private final int mPort;
+
+        EncapSocketRecord(int resourceId, FileDescriptor socket, int port) {
+            super(resourceId);
+            mSocket = socket;
+            mPort = port;
+        }
+
+        /** always guarded by IpSecService#this */
+        @Override
+        public void freeUnderlyingResources() {
+            Log.d(TAG, "Closing port " + mPort);
+            IoUtils.closeQuietly(mSocket);
+            mSocket = null;
+
+            getResourceTracker().give();
+        }
+
+        public int getPort() {
+            return mPort;
+        }
+
+        public FileDescriptor getFileDescriptor() {
+            return mSocket;
+        }
+
+        @Override
+        protected ResourceTracker getResourceTracker() {
+            return getUserRecord().mSocketQuotaTracker;
+        }
+
+        @Override
+        public void invalidate() {
+            getUserRecord().removeEncapSocketRecord(mResourceId);
+        }
+
+        @Override
+        public String toString() {
+            return new StringBuilder()
+                    .append("{super=")
+                    .append(super.toString())
+                    .append(", mSocket=")
+                    .append(mSocket)
+                    .append(", mPort=")
+                    .append(mPort)
+                    .append("}")
+                    .toString();
+        }
+    }
+
+    /**
+     * Constructs a new IpSecService instance
+     *
+     * @param context Binder context for this service
+     */
+    public IpSecService(Context context) {
+        this(context, new Dependencies());
+    }
+
+    @NonNull
+    private AppOpsManager getAppOpsManager() {
+        AppOpsManager appOps = (AppOpsManager) mContext.getSystemService(Context.APP_OPS_SERVICE);
+        if (appOps == null) throw new RuntimeException("System Server couldn't get AppOps");
+        return appOps;
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public IpSecService(Context context, Dependencies deps) {
+        this(
+                context,
+                deps,
+                (fd, uid) -> {
+                    try {
+                        TrafficStats.setThreadStatsUid(uid);
+                        TrafficStats.tagFileDescriptor(fd);
+                    } finally {
+                        TrafficStats.clearThreadStatsUid();
+                    }
+                });
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public IpSecService(Context context, Dependencies deps, UidFdTagger uidFdTagger) {
+        mContext = context;
+        mDeps = Objects.requireNonNull(deps, "Missing dependencies.");
+        mUidFdTagger = uidFdTagger;
+        try {
+            mNetd = mDeps.getNetdInstance(mContext);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Checks that the provided InetAddress is valid for use in an IPsec SA. The address must not be
+     * a wildcard address and must be in a numeric form such as 1.2.3.4 or 2001::1.
+     */
+    private static void checkInetAddress(String inetAddress) {
+        if (TextUtils.isEmpty(inetAddress)) {
+            throw new IllegalArgumentException("Unspecified address");
+        }
+
+        InetAddress checkAddr = InetAddresses.parseNumericAddress(inetAddress);
+
+        if (checkAddr.isAnyLocalAddress()) {
+            throw new IllegalArgumentException("Inappropriate wildcard address: " + inetAddress);
+        }
+    }
+
+    /**
+     * Checks the user-provided direction field and throws an IllegalArgumentException if it is not
+     * DIRECTION_IN or DIRECTION_OUT
+     */
+    private void checkDirection(int direction) {
+        switch (direction) {
+            case IpSecManager.DIRECTION_OUT:
+            case IpSecManager.DIRECTION_IN:
+                return;
+            case IpSecManager.DIRECTION_FWD:
+                // Only NETWORK_STACK or MAINLINE_NETWORK_STACK allowed to use forward policies
+                PermissionUtils.enforceNetworkStackPermission(mContext);
+                return;
+        }
+        throw new IllegalArgumentException("Invalid Direction: " + direction);
+    }
+
+    /** Get a new SPI and maintain the reservation in the system server */
+    @Override
+    public synchronized IpSecSpiResponse allocateSecurityParameterIndex(
+            String destinationAddress, int requestedSpi, IBinder binder) throws RemoteException {
+        checkInetAddress(destinationAddress);
+        // RFC 4303 Section 2.1 - 0=local, 1-255=reserved.
+        if (requestedSpi > 0 && requestedSpi < 256) {
+            throw new IllegalArgumentException("ESP SPI must not be in the range of 0-255.");
+        }
+        Objects.requireNonNull(binder, "Null Binder passed to allocateSecurityParameterIndex");
+
+        int callingUid = Binder.getCallingUid();
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(callingUid);
+        final int resourceId = mNextResourceId++;
+
+        int spi = IpSecManager.INVALID_SECURITY_PARAMETER_INDEX;
+        try {
+            if (!userRecord.mSpiQuotaTracker.isAvailable()) {
+                return new IpSecSpiResponse(
+                        IpSecManager.Status.RESOURCE_UNAVAILABLE, INVALID_RESOURCE_ID, spi);
+            }
+
+            spi = mNetd.ipSecAllocateSpi(callingUid, "", destinationAddress, requestedSpi);
+            Log.d(TAG, "Allocated SPI " + spi);
+            userRecord.mSpiRecords.put(
+                    resourceId,
+                    new RefcountedResource<SpiRecord>(
+                            new SpiRecord(resourceId, "",
+                            destinationAddress, spi), binder));
+        } catch (ServiceSpecificException e) {
+            if (e.errorCode == OsConstants.ENOENT) {
+                return new IpSecSpiResponse(
+                        IpSecManager.Status.SPI_UNAVAILABLE, INVALID_RESOURCE_ID, spi);
+            }
+            throw e;
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+        return new IpSecSpiResponse(IpSecManager.Status.OK, resourceId, spi);
+    }
+
+    /* This method should only be called from Binder threads. Do not call this from
+     * within the system server as it will crash the system on failure.
+     */
+    private void releaseResource(RefcountedResourceArray resArray, int resourceId)
+            throws RemoteException {
+        resArray.getRefcountedResourceOrThrow(resourceId).userRelease();
+    }
+
+    /** Release a previously allocated SPI that has been registered with the system server */
+    @Override
+    public synchronized void releaseSecurityParameterIndex(int resourceId) throws RemoteException {
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+        releaseResource(userRecord.mSpiRecords, resourceId);
+    }
+
+    /**
+     * This function finds and forcibly binds to a random system port, ensuring that the port cannot
+     * be unbound.
+     *
+     * <p>A socket cannot be un-bound from a port if it was bound to that port by number. To select
+     * a random open port and then bind by number, this function creates a temp socket, binds to a
+     * random port (specifying 0), gets that port number, and then uses is to bind the user's UDP
+     * Encapsulation Socket forcibly, so that it cannot be un-bound by the user with the returned
+     * FileHandle.
+     *
+     * <p>The loop in this function handles the inherent race window between un-binding to a port
+     * and re-binding, during which the system could *technically* hand that port out to someone
+     * else.
+     */
+    private int bindToRandomPort(FileDescriptor sockFd) throws IOException {
+        for (int i = MAX_PORT_BIND_ATTEMPTS; i > 0; i--) {
+            try {
+                FileDescriptor probeSocket = Os.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+                Os.bind(probeSocket, INADDR_ANY, 0);
+                int port = ((InetSocketAddress) Os.getsockname(probeSocket)).getPort();
+                Os.close(probeSocket);
+                Log.v(TAG, "Binding to port " + port);
+                Os.bind(sockFd, INADDR_ANY, port);
+                return port;
+            } catch (ErrnoException e) {
+                // Someone miraculously claimed the port just after we closed probeSocket.
+                if (e.errno == OsConstants.EADDRINUSE) {
+                    continue;
+                }
+                throw e.rethrowAsIOException();
+            }
+        }
+        throw new IOException("Failed " + MAX_PORT_BIND_ATTEMPTS + " attempts to bind to a port");
+    }
+
+    /**
+     * Functional interface to do traffic tagging of given sockets to UIDs.
+     *
+     * <p>Specifically used by openUdpEncapsulationSocket to ensure data usage on the UDP encap
+     * sockets are billed to the UID that the UDP encap socket was created on behalf of.
+     *
+     * <p>Separate class so that the socket tagging logic can be mocked; TrafficStats uses static
+     * methods that cannot be easily mocked/tested.
+     */
+    @VisibleForTesting
+    public interface UidFdTagger {
+        /**
+         * Sets socket tag to assign all traffic to the provided UID.
+         *
+         * <p>Since the socket is created on behalf of an unprivileged application, all traffic
+         * should be accounted to the UID of the unprivileged application.
+         */
+        void tag(FileDescriptor fd, int uid) throws IOException;
+    }
+
+    /**
+     * Open a socket via the system server and bind it to the specified port (random if port=0).
+     * This will return a PFD to the user that represent a bound UDP socket. The system server will
+     * cache the socket and a record of its owner so that it can and must be freed when no longer
+     * needed.
+     */
+    @Override
+    public synchronized IpSecUdpEncapResponse openUdpEncapsulationSocket(int port, IBinder binder)
+            throws RemoteException {
+        if (port != 0 && (port < FREE_PORT_MIN || port > PORT_MAX)) {
+            throw new IllegalArgumentException(
+                    "Specified port number must be a valid non-reserved UDP port");
+        }
+        Objects.requireNonNull(binder, "Null Binder passed to openUdpEncapsulationSocket");
+
+        int callingUid = Binder.getCallingUid();
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(callingUid);
+        final int resourceId = mNextResourceId++;
+
+        ParcelFileDescriptor pFd = null;
+        try {
+            if (!userRecord.mSocketQuotaTracker.isAvailable()) {
+                return new IpSecUdpEncapResponse(IpSecManager.Status.RESOURCE_UNAVAILABLE);
+            }
+
+            FileDescriptor sockFd = null;
+            try {
+                sockFd = Os.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+                pFd = ParcelFileDescriptor.dup(sockFd);
+            } finally {
+                IoUtils.closeQuietly(sockFd);
+            }
+
+            mUidFdTagger.tag(pFd.getFileDescriptor(), callingUid);
+            // This code is common to both the unspecified and specified port cases
+            Os.setsockoptInt(
+                    pFd.getFileDescriptor(),
+                    OsConstants.IPPROTO_UDP,
+                    OsConstants.UDP_ENCAP,
+                    OsConstants.UDP_ENCAP_ESPINUDP);
+
+            mNetd.ipSecSetEncapSocketOwner(pFd, callingUid);
+            if (port != 0) {
+                Log.v(TAG, "Binding to port " + port);
+                Os.bind(pFd.getFileDescriptor(), INADDR_ANY, port);
+            } else {
+                port = bindToRandomPort(pFd.getFileDescriptor());
+            }
+
+            userRecord.mEncapSocketRecords.put(
+                    resourceId,
+                    new RefcountedResource<EncapSocketRecord>(
+                            new EncapSocketRecord(resourceId, pFd.getFileDescriptor(), port),
+                            binder));
+            return new IpSecUdpEncapResponse(IpSecManager.Status.OK, resourceId, port,
+                    pFd.getFileDescriptor());
+        } catch (IOException | ErrnoException e) {
+            try {
+                if (pFd != null) {
+                    pFd.close();
+                }
+            } catch (IOException ex) {
+                // Nothing can be done at this point
+                Log.e(TAG, "Failed to close pFd.");
+            }
+        }
+        // If we make it to here, then something has gone wrong and we couldn't open a socket.
+        // The only reasonable condition that would cause that is resource unavailable.
+        return new IpSecUdpEncapResponse(IpSecManager.Status.RESOURCE_UNAVAILABLE);
+    }
+
+    /** close a socket that has been been allocated by and registered with the system server */
+    @Override
+    public synchronized void closeUdpEncapsulationSocket(int resourceId) throws RemoteException {
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+        releaseResource(userRecord.mEncapSocketRecords, resourceId);
+    }
+
+    /**
+     * Create a tunnel interface for use in IPSec tunnel mode. The system server will cache the
+     * tunnel interface and a record of its owner so that it can and must be freed when no longer
+     * needed.
+     */
+    @Override
+    public synchronized IpSecTunnelInterfaceResponse createTunnelInterface(
+            String localAddr, String remoteAddr, Network underlyingNetwork, IBinder binder,
+            String callingPackage) {
+        enforceTunnelFeatureAndPermissions(callingPackage);
+        Objects.requireNonNull(binder, "Null Binder passed to createTunnelInterface");
+        Objects.requireNonNull(underlyingNetwork, "No underlying network was specified");
+        checkInetAddress(localAddr);
+        checkInetAddress(remoteAddr);
+
+        // TODO: Check that underlying network exists, and IP addresses not assigned to a different
+        //       network (b/72316676).
+
+        int callerUid = Binder.getCallingUid();
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(callerUid);
+        if (!userRecord.mTunnelQuotaTracker.isAvailable()) {
+            return new IpSecTunnelInterfaceResponse(IpSecManager.Status.RESOURCE_UNAVAILABLE);
+        }
+
+        final int resourceId = mNextResourceId++;
+        final int ikey = reserveNetId();
+        final int okey = reserveNetId();
+        String intfName = String.format("%s%d", INetd.IPSEC_INTERFACE_PREFIX, resourceId);
+
+        try {
+            // Calls to netd:
+            //       Create VTI
+            //       Add inbound/outbound global policies
+            //              (use reqid = 0)
+            mNetd.ipSecAddTunnelInterface(intfName, localAddr, remoteAddr, ikey, okey, resourceId);
+
+            BinderUtils.withCleanCallingIdentity(() -> {
+                NetdUtils.setInterfaceUp(mNetd, intfName);
+            });
+
+            for (int selAddrFamily : ADDRESS_FAMILIES) {
+                // Always send down correct local/remote addresses for template.
+                mNetd.ipSecAddSecurityPolicy(
+                        callerUid,
+                        selAddrFamily,
+                        IpSecManager.DIRECTION_OUT,
+                        localAddr,
+                        remoteAddr,
+                        0,
+                        okey,
+                        0xffffffff,
+                        resourceId);
+                mNetd.ipSecAddSecurityPolicy(
+                        callerUid,
+                        selAddrFamily,
+                        IpSecManager.DIRECTION_IN,
+                        remoteAddr,
+                        localAddr,
+                        0,
+                        ikey,
+                        0xffffffff,
+                        resourceId);
+
+                // Add a forwarding policy on the tunnel interface. In order to support forwarding
+                // the IpSecTunnelInterface must have a forwarding policy matching the incoming SA.
+                //
+                // Unless a IpSecTransform is also applied against this interface in DIRECTION_FWD,
+                // forwarding will be blocked by default (as would be the case if this policy was
+                // absent).
+                //
+                // This is necessary only on the tunnel interface, and not any the interface to
+                // which traffic will be forwarded to.
+                mNetd.ipSecAddSecurityPolicy(
+                        callerUid,
+                        selAddrFamily,
+                        IpSecManager.DIRECTION_FWD,
+                        remoteAddr,
+                        localAddr,
+                        0,
+                        ikey,
+                        0xffffffff,
+                        resourceId);
+            }
+
+            userRecord.mTunnelInterfaceRecords.put(
+                    resourceId,
+                    new RefcountedResource<TunnelInterfaceRecord>(
+                            new TunnelInterfaceRecord(
+                                    resourceId,
+                                    intfName,
+                                    underlyingNetwork,
+                                    localAddr,
+                                    remoteAddr,
+                                    ikey,
+                                    okey,
+                                    resourceId),
+                            binder));
+            return new IpSecTunnelInterfaceResponse(IpSecManager.Status.OK, resourceId, intfName);
+        } catch (RemoteException e) {
+            // Release keys if we got an error.
+            releaseNetId(ikey);
+            releaseNetId(okey);
+            throw e.rethrowFromSystemServer();
+        } catch (Throwable t) {
+            // Release keys if we got an error.
+            releaseNetId(ikey);
+            releaseNetId(okey);
+            throw t;
+        }
+    }
+
+    /**
+     * Adds a new local address to the tunnel interface. This allows packets to be sent and received
+     * from multiple local IP addresses over the same tunnel.
+     */
+    @Override
+    public synchronized void addAddressToTunnelInterface(
+            int tunnelResourceId, LinkAddress localAddr, String callingPackage) {
+        enforceTunnelFeatureAndPermissions(callingPackage);
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+
+        // Get tunnelInterface record; if no such interface is found, will throw
+        // IllegalArgumentException
+        TunnelInterfaceRecord tunnelInterfaceInfo =
+                userRecord.mTunnelInterfaceRecords.getResourceOrThrow(tunnelResourceId);
+
+        try {
+            // We can assume general validity of the IP address, since we get them as a
+            // LinkAddress, which does some validation.
+            mNetd.interfaceAddAddress(
+                    tunnelInterfaceInfo.mInterfaceName,
+                    localAddr.getAddress().getHostAddress(),
+                    localAddr.getPrefixLength());
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove a new local address from the tunnel interface. After removal, the address will no
+     * longer be available to send from, or receive on.
+     */
+    @Override
+    public synchronized void removeAddressFromTunnelInterface(
+            int tunnelResourceId, LinkAddress localAddr, String callingPackage) {
+        enforceTunnelFeatureAndPermissions(callingPackage);
+
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+        // Get tunnelInterface record; if no such interface is found, will throw
+        // IllegalArgumentException
+        TunnelInterfaceRecord tunnelInterfaceInfo =
+                userRecord.mTunnelInterfaceRecords.getResourceOrThrow(tunnelResourceId);
+
+        try {
+            // We can assume general validity of the IP address, since we get them as a
+            // LinkAddress, which does some validation.
+            mNetd.interfaceDelAddress(
+                            tunnelInterfaceInfo.mInterfaceName,
+                            localAddr.getAddress().getHostAddress(),
+                            localAddr.getPrefixLength());
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** Set TunnelInterface to use a specific underlying network. */
+    @Override
+    public synchronized void setNetworkForTunnelInterface(
+            int tunnelResourceId, Network underlyingNetwork, String callingPackage) {
+        enforceTunnelFeatureAndPermissions(callingPackage);
+        Objects.requireNonNull(underlyingNetwork, "No underlying network was specified");
+
+        final UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+
+        // Get tunnelInterface record; if no such interface is found, will throw
+        // IllegalArgumentException. userRecord.mTunnelInterfaceRecords is never null
+        final TunnelInterfaceRecord tunnelInterfaceInfo =
+                userRecord.mTunnelInterfaceRecords.getResourceOrThrow(tunnelResourceId);
+
+        final ConnectivityManager connectivityManager =
+                mContext.getSystemService(ConnectivityManager.class);
+        final LinkProperties lp = connectivityManager.getLinkProperties(underlyingNetwork);
+        if (tunnelInterfaceInfo.getInterfaceName().equals(lp.getInterfaceName())) {
+            throw new IllegalArgumentException(
+                    "Underlying network cannot be the network being exposed by this tunnel");
+        }
+
+        // It is meaningless to check if the network exists or is valid because the network might
+        // disconnect at any time after it passes the check.
+
+        tunnelInterfaceInfo.setUnderlyingNetwork(underlyingNetwork);
+    }
+
+    /**
+     * Delete a TunnelInterface that has been been allocated by and registered with the system
+     * server
+     */
+    @Override
+    public synchronized void deleteTunnelInterface(
+            int resourceId, String callingPackage) throws RemoteException {
+        enforceTunnelFeatureAndPermissions(callingPackage);
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+        releaseResource(userRecord.mTunnelInterfaceRecords, resourceId);
+    }
+
+    @VisibleForTesting
+    void validateAlgorithms(IpSecConfig config) throws IllegalArgumentException {
+        IpSecAlgorithm auth = config.getAuthentication();
+        IpSecAlgorithm crypt = config.getEncryption();
+        IpSecAlgorithm aead = config.getAuthenticatedEncryption();
+
+        // Validate the algorithm set
+        Preconditions.checkArgument(
+                aead != null || crypt != null || auth != null,
+                "No Encryption or Authentication algorithms specified");
+        Preconditions.checkArgument(
+                auth == null || auth.isAuthentication(),
+                "Unsupported algorithm for Authentication");
+        Preconditions.checkArgument(
+                crypt == null || crypt.isEncryption(), "Unsupported algorithm for Encryption");
+        Preconditions.checkArgument(
+                aead == null || aead.isAead(),
+                "Unsupported algorithm for Authenticated Encryption");
+        Preconditions.checkArgument(
+                aead == null || (auth == null && crypt == null),
+                "Authenticated Encryption is mutually exclusive with other Authentication "
+                        + "or Encryption algorithms");
+    }
+
+    private int getFamily(String inetAddress) {
+        int family = AF_UNSPEC;
+        InetAddress checkAddress = InetAddresses.parseNumericAddress(inetAddress);
+        if (checkAddress instanceof Inet4Address) {
+            family = AF_INET;
+        } else if (checkAddress instanceof Inet6Address) {
+            family = AF_INET6;
+        }
+        return family;
+    }
+
+    /**
+     * Checks an IpSecConfig parcel to ensure that the contents are valid and throws an
+     * IllegalArgumentException if they are not.
+     */
+    private void checkIpSecConfig(IpSecConfig config) {
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+
+        switch (config.getEncapType()) {
+            case IpSecTransform.ENCAP_NONE:
+                break;
+            case IpSecTransform.ENCAP_ESPINUDP:
+            case IpSecTransform.ENCAP_ESPINUDP_NON_IKE:
+                // Retrieve encap socket record; will throw IllegalArgumentException if not found
+                userRecord.mEncapSocketRecords.getResourceOrThrow(
+                        config.getEncapSocketResourceId());
+
+                int port = config.getEncapRemotePort();
+                if (port <= 0 || port > 0xFFFF) {
+                    throw new IllegalArgumentException("Invalid remote UDP port: " + port);
+                }
+                break;
+            default:
+                throw new IllegalArgumentException("Invalid Encap Type: " + config.getEncapType());
+        }
+
+        validateAlgorithms(config);
+
+        // Retrieve SPI record; will throw IllegalArgumentException if not found
+        SpiRecord s = userRecord.mSpiRecords.getResourceOrThrow(config.getSpiResourceId());
+
+        // Check to ensure that SPI has not already been used.
+        if (s.getOwnedByTransform()) {
+            throw new IllegalStateException("SPI already in use; cannot be used in new Transforms");
+        }
+
+        // If no remote address is supplied, then use one from the SPI.
+        if (TextUtils.isEmpty(config.getDestinationAddress())) {
+            config.setDestinationAddress(s.getDestinationAddress());
+        }
+
+        // All remote addresses must match
+        if (!config.getDestinationAddress().equals(s.getDestinationAddress())) {
+            throw new IllegalArgumentException("Mismatched remote addresseses.");
+        }
+
+        // This check is technically redundant due to the chain of custody between the SPI and
+        // the IpSecConfig, but in the future if the dest is allowed to be set explicitly in
+        // the transform, this will prevent us from messing up.
+        checkInetAddress(config.getDestinationAddress());
+
+        // Require a valid source address for all transforms.
+        checkInetAddress(config.getSourceAddress());
+
+        // Check to ensure source and destination have the same address family.
+        String sourceAddress = config.getSourceAddress();
+        String destinationAddress = config.getDestinationAddress();
+        int sourceFamily = getFamily(sourceAddress);
+        int destinationFamily = getFamily(destinationAddress);
+        if (sourceFamily != destinationFamily) {
+            throw new IllegalArgumentException(
+                    "Source address ("
+                            + sourceAddress
+                            + ") and destination address ("
+                            + destinationAddress
+                            + ") have different address families.");
+        }
+
+        // Throw an error if UDP Encapsulation is not used in IPv4.
+        if (config.getEncapType() != IpSecTransform.ENCAP_NONE && sourceFamily != AF_INET) {
+            throw new IllegalArgumentException(
+                    "UDP Encapsulation is not supported for this address family");
+        }
+
+        switch (config.getMode()) {
+            case IpSecTransform.MODE_TRANSPORT:
+                break;
+            case IpSecTransform.MODE_TUNNEL:
+                break;
+            default:
+                throw new IllegalArgumentException(
+                        "Invalid IpSecTransform.mode: " + config.getMode());
+        }
+
+        config.setMarkValue(0);
+        config.setMarkMask(0);
+    }
+
+    private static final String TUNNEL_OP = AppOpsManager.OPSTR_MANAGE_IPSEC_TUNNELS;
+
+    private void enforceTunnelFeatureAndPermissions(String callingPackage) {
+        if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_IPSEC_TUNNELS)) {
+            throw new UnsupportedOperationException(
+                    "IPsec Tunnel Mode requires PackageManager.FEATURE_IPSEC_TUNNELS");
+        }
+
+        Objects.requireNonNull(callingPackage, "Null calling package cannot create IpSec tunnels");
+
+        // OP_MANAGE_IPSEC_TUNNELS will return MODE_ERRORED by default, including for the system
+        // server. If the appop is not granted, require that the caller has the MANAGE_IPSEC_TUNNELS
+        // permission or is the System Server.
+        if (AppOpsManager.MODE_ALLOWED == getAppOpsManager().noteOpNoThrow(
+                TUNNEL_OP, Binder.getCallingUid(), callingPackage)) {
+            return;
+        }
+        mContext.enforceCallingOrSelfPermission(
+                android.Manifest.permission.MANAGE_IPSEC_TUNNELS, "IpSecService");
+    }
+
+    private void createOrUpdateTransform(
+            IpSecConfig c, int resourceId, SpiRecord spiRecord, EncapSocketRecord socketRecord)
+            throws RemoteException {
+
+        int encapType = c.getEncapType(), encapLocalPort = 0, encapRemotePort = 0;
+        if (encapType != IpSecTransform.ENCAP_NONE) {
+            encapLocalPort = socketRecord.getPort();
+            encapRemotePort = c.getEncapRemotePort();
+        }
+
+        IpSecAlgorithm auth = c.getAuthentication();
+        IpSecAlgorithm crypt = c.getEncryption();
+        IpSecAlgorithm authCrypt = c.getAuthenticatedEncryption();
+
+        String cryptName;
+        if (crypt == null) {
+            cryptName = (authCrypt == null) ? IpSecAlgorithm.CRYPT_NULL : "";
+        } else {
+            cryptName = crypt.getName();
+        }
+
+        mNetd.ipSecAddSecurityAssociation(
+                Binder.getCallingUid(),
+                c.getMode(),
+                c.getSourceAddress(),
+                c.getDestinationAddress(),
+                (c.getNetwork() != null) ? c.getNetwork().getNetId() : 0,
+                spiRecord.getSpi(),
+                c.getMarkValue(),
+                c.getMarkMask(),
+                (auth != null) ? auth.getName() : "",
+                (auth != null) ? auth.getKey() : new byte[] {},
+                (auth != null) ? auth.getTruncationLengthBits() : 0,
+                cryptName,
+                (crypt != null) ? crypt.getKey() : new byte[] {},
+                (crypt != null) ? crypt.getTruncationLengthBits() : 0,
+                (authCrypt != null) ? authCrypt.getName() : "",
+                (authCrypt != null) ? authCrypt.getKey() : new byte[] {},
+                (authCrypt != null) ? authCrypt.getTruncationLengthBits() : 0,
+                encapType,
+                encapLocalPort,
+                encapRemotePort,
+                c.getXfrmInterfaceId());
+    }
+
+    /**
+     * Create a IPsec transform, which represents a single security association in the kernel. The
+     * transform will be cached by the system server and must be freed when no longer needed. It is
+     * possible to free one, deleting the SA from underneath sockets that are using it, which will
+     * result in all of those sockets becoming unable to send or receive data.
+     */
+    @Override
+    public synchronized IpSecTransformResponse createTransform(
+            IpSecConfig c, IBinder binder, String callingPackage) throws RemoteException {
+        Objects.requireNonNull(c);
+        if (c.getMode() == IpSecTransform.MODE_TUNNEL) {
+            enforceTunnelFeatureAndPermissions(callingPackage);
+        }
+        checkIpSecConfig(c);
+        Objects.requireNonNull(binder, "Null Binder passed to createTransform");
+        final int resourceId = mNextResourceId++;
+
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+        List<RefcountedResource> dependencies = new ArrayList<>();
+
+        if (!userRecord.mTransformQuotaTracker.isAvailable()) {
+            return new IpSecTransformResponse(IpSecManager.Status.RESOURCE_UNAVAILABLE);
+        }
+
+        EncapSocketRecord socketRecord = null;
+        if (c.getEncapType() != IpSecTransform.ENCAP_NONE) {
+            RefcountedResource<EncapSocketRecord> refcountedSocketRecord =
+                    userRecord.mEncapSocketRecords.getRefcountedResourceOrThrow(
+                            c.getEncapSocketResourceId());
+            dependencies.add(refcountedSocketRecord);
+            socketRecord = refcountedSocketRecord.getResource();
+        }
+
+        RefcountedResource<SpiRecord> refcountedSpiRecord =
+                userRecord.mSpiRecords.getRefcountedResourceOrThrow(c.getSpiResourceId());
+        dependencies.add(refcountedSpiRecord);
+        SpiRecord spiRecord = refcountedSpiRecord.getResource();
+
+        createOrUpdateTransform(c, resourceId, spiRecord, socketRecord);
+
+        // SA was created successfully, time to construct a record and lock it away
+        userRecord.mTransformRecords.put(
+                resourceId,
+                new RefcountedResource<TransformRecord>(
+                        new TransformRecord(resourceId, c, spiRecord, socketRecord),
+                        binder,
+                        dependencies.toArray(new RefcountedResource[dependencies.size()])));
+        return new IpSecTransformResponse(IpSecManager.Status.OK, resourceId);
+    }
+
+    /**
+     * Delete a transport mode transform that was previously allocated by + registered with the
+     * system server. If this is called on an inactive (or non-existent) transform, it will not
+     * return an error. It's safe to de-allocate transforms that may have already been deleted for
+     * other reasons.
+     */
+    @Override
+    public synchronized void deleteTransform(int resourceId) throws RemoteException {
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(Binder.getCallingUid());
+        releaseResource(userRecord.mTransformRecords, resourceId);
+    }
+
+    /**
+     * Apply an active transport mode transform to a socket, which will apply the IPsec security
+     * association as a correspondent policy to the provided socket
+     */
+    @Override
+    public synchronized void applyTransportModeTransform(
+            ParcelFileDescriptor socket, int direction, int resourceId) throws RemoteException {
+        int callingUid = Binder.getCallingUid();
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(callingUid);
+        checkDirection(direction);
+        // Get transform record; if no transform is found, will throw IllegalArgumentException
+        TransformRecord info = userRecord.mTransformRecords.getResourceOrThrow(resourceId);
+
+        // TODO: make this a function.
+        if (info.mPid != getCallingPid() || info.mUid != callingUid) {
+            throw new SecurityException("Only the owner of an IpSec Transform may apply it!");
+        }
+
+        // Get config and check that to-be-applied transform has the correct mode
+        IpSecConfig c = info.getConfig();
+        Preconditions.checkArgument(
+                c.getMode() == IpSecTransform.MODE_TRANSPORT,
+                "Transform mode was not Transport mode; cannot be applied to a socket");
+
+        mNetd.ipSecApplyTransportModeTransform(
+                socket,
+                callingUid,
+                direction,
+                c.getSourceAddress(),
+                c.getDestinationAddress(),
+                info.getSpiRecord().getSpi());
+    }
+
+    /**
+     * Remove transport mode transforms from a socket, applying the default (empty) policy. This
+     * ensures that NO IPsec policy is applied to the socket (would be the equivalent of applying a
+     * policy that performs no IPsec). Today the resourceId parameter is passed but not used:
+     * reserved for future improved input validation.
+     */
+    @Override
+    public synchronized void removeTransportModeTransforms(ParcelFileDescriptor socket)
+            throws RemoteException {
+        mNetd.ipSecRemoveTransportModeTransform(socket);
+    }
+
+    /**
+     * Apply an active tunnel mode transform to a TunnelInterface, which will apply the IPsec
+     * security association as a correspondent policy to the provided interface
+     */
+    @Override
+    public synchronized void applyTunnelModeTransform(
+            int tunnelResourceId, int direction,
+            int transformResourceId, String callingPackage) throws RemoteException {
+        enforceTunnelFeatureAndPermissions(callingPackage);
+        checkDirection(direction);
+
+        int callingUid = Binder.getCallingUid();
+        UserRecord userRecord = mUserResourceTracker.getUserRecord(callingUid);
+
+        // Get transform record; if no transform is found, will throw IllegalArgumentException
+        TransformRecord transformInfo =
+                userRecord.mTransformRecords.getResourceOrThrow(transformResourceId);
+
+        // Get tunnelInterface record; if no such interface is found, will throw
+        // IllegalArgumentException
+        TunnelInterfaceRecord tunnelInterfaceInfo =
+                userRecord.mTunnelInterfaceRecords.getResourceOrThrow(tunnelResourceId);
+
+        // Get config and check that to-be-applied transform has the correct mode
+        IpSecConfig c = transformInfo.getConfig();
+        Preconditions.checkArgument(
+                c.getMode() == IpSecTransform.MODE_TUNNEL,
+                "Transform mode was not Tunnel mode; cannot be applied to a tunnel interface");
+
+        EncapSocketRecord socketRecord = null;
+        if (c.getEncapType() != IpSecTransform.ENCAP_NONE) {
+            socketRecord =
+                    userRecord.mEncapSocketRecords.getResourceOrThrow(c.getEncapSocketResourceId());
+        }
+        SpiRecord spiRecord = transformInfo.getSpiRecord();
+
+        int mark =
+                (direction == IpSecManager.DIRECTION_OUT)
+                        ? tunnelInterfaceInfo.getOkey()
+                        : tunnelInterfaceInfo.getIkey(); // Ikey also used for FWD policies
+
+        try {
+            // Default to using the invalid SPI of 0 for inbound SAs. This allows policies to skip
+            // SPI matching as part of the template resolution.
+            int spi = IpSecManager.INVALID_SECURITY_PARAMETER_INDEX;
+            c.setXfrmInterfaceId(tunnelInterfaceInfo.getIfId());
+
+            // TODO: enable this when UPDSA supports updating marks. Adding kernel support upstream
+            //     (and backporting) would allow us to narrow the mark space, and ensure that the SA
+            //     and SPs have matching marks (as VTI are meant to be built).
+            // Currently update does nothing with marks. Leave empty (defaulting to 0) to ensure the
+            //     config matches the actual allocated resources in the kernel.
+            // All SAs will have zero marks (from creation time), and any policy that matches the
+            //     same src/dst could match these SAs. Non-IpSecService governed processes that
+            //     establish floating policies with the same src/dst may result in undefined
+            //     behavior. This is generally limited to vendor code due to the permissions
+            //     (CAP_NET_ADMIN) required.
+            //
+            // c.setMarkValue(mark);
+            // c.setMarkMask(0xffffffff);
+
+            if (direction == IpSecManager.DIRECTION_OUT) {
+                // Set output mark via underlying network (output only)
+                c.setNetwork(tunnelInterfaceInfo.getUnderlyingNetwork());
+
+                // Set outbound SPI only. We want inbound to use any valid SA (old, new) on rekeys,
+                // but want to guarantee outbound packets are sent over the new SA.
+                spi = spiRecord.getSpi();
+            }
+
+            // Always update the policy with the relevant XFRM_IF_ID
+            for (int selAddrFamily : ADDRESS_FAMILIES) {
+                mNetd.ipSecUpdateSecurityPolicy(
+                        callingUid,
+                        selAddrFamily,
+                        direction,
+                        transformInfo.getConfig().getSourceAddress(),
+                        transformInfo.getConfig().getDestinationAddress(),
+                        spi, // If outbound, also add SPI to the policy.
+                        mark, // Must always set policy mark; ikey/okey for VTIs
+                        0xffffffff,
+                        c.getXfrmInterfaceId());
+            }
+
+            // Update SA with tunnel mark (ikey or okey based on direction)
+            createOrUpdateTransform(c, transformResourceId, spiRecord, socketRecord);
+        } catch (ServiceSpecificException e) {
+            if (e.errorCode == EINVAL) {
+                throw new IllegalArgumentException(e.toString());
+            } else {
+                throw e;
+            }
+        }
+    }
+
+    @Override
+    protected synchronized void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        mContext.enforceCallingOrSelfPermission(DUMP, TAG);
+
+        pw.println("IpSecService dump:");
+        pw.println();
+
+        pw.println("mUserResourceTracker:");
+        pw.println(mUserResourceTracker);
+    }
+}
diff --git a/service-t/src/com/android/server/NativeDaemonConnector.java b/service-t/src/com/android/server/NativeDaemonConnector.java
new file mode 100644
index 0000000..ec8d779
--- /dev/null
+++ b/service-t/src/com/android/server/NativeDaemonConnector.java
@@ -0,0 +1,704 @@
+/*
+ * Copyright (C) 2007 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.LocalSocket;
+import android.net.LocalSocketAddress;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.PowerManager;
+import android.os.SystemClock;
+import android.util.LocalLog;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.Objects;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Generic connector class for interfacing with a native daemon which uses the
+ * {@code libsysutils} FrameworkListener protocol.
+ */
+final class NativeDaemonConnector implements Runnable, Handler.Callback {
+    private final static boolean VDBG = false;
+
+    private final String TAG;
+
+    private String mSocket;
+    private OutputStream mOutputStream;
+    private LocalLog mLocalLog;
+
+    private volatile boolean mDebug = false;
+    private volatile Object mWarnIfHeld;
+
+    private final ResponseQueue mResponseQueue;
+
+    private final PowerManager.WakeLock mWakeLock;
+
+    private final Looper mLooper;
+
+    private INativeDaemonConnectorCallbacks mCallbacks;
+    private Handler mCallbackHandler;
+
+    private AtomicInteger mSequenceNumber;
+
+    private static final long DEFAULT_TIMEOUT = 1 * 60 * 1000; /* 1 minute */
+    private static final long WARN_EXECUTE_DELAY_MS = 500; /* .5 sec */
+
+    /** Lock held whenever communicating with native daemon. */
+    private final Object mDaemonLock = new Object();
+
+    private final int BUFFER_SIZE = 4096;
+
+    NativeDaemonConnector(INativeDaemonConnectorCallbacks callbacks, String socket,
+            int responseQueueSize, String logTag, int maxLogSize, PowerManager.WakeLock wl) {
+        mCallbacks = callbacks;
+        mSocket = socket;
+        mResponseQueue = new ResponseQueue(responseQueueSize);
+        mWakeLock = wl;
+        if (mWakeLock != null) {
+            mWakeLock.setReferenceCounted(true);
+        }
+        mSequenceNumber = new AtomicInteger(0);
+        TAG = logTag != null ? logTag : "NativeDaemonConnector";
+        mLocalLog = new LocalLog(maxLogSize);
+        final HandlerThread thread = new HandlerThread(TAG);
+        thread.start();
+        mLooper = thread.getLooper();
+    }
+
+    /**
+     * Enable Set debugging mode, which causes messages to also be written to both
+     * {@link Log} in addition to internal log.
+     */
+    public void setDebug(boolean debug) {
+        mDebug = debug;
+    }
+
+    /**
+     * Like SystemClock.uptimeMillis, except truncated to an int so it will fit in a message arg.
+     * Inaccurate across 49.7 days of uptime, but only used for debugging.
+     */
+    private int uptimeMillisInt() {
+        return (int) SystemClock.uptimeMillis() & Integer.MAX_VALUE;
+    }
+
+    /**
+     * Yell loudly if someone tries making future {@link #execute(Command)}
+     * calls while holding a lock on the given object.
+     */
+    public void setWarnIfHeld(Object warnIfHeld) {
+        if (mWarnIfHeld != null) {
+            throw new IllegalStateException("warnIfHeld is already set.");
+        }
+        mWarnIfHeld = Objects.requireNonNull(warnIfHeld);
+    }
+
+    @Override
+    public void run() {
+        mCallbackHandler = new Handler(mLooper, this);
+
+        while (true) {
+            try {
+                listenToSocket();
+            } catch (Exception e) {
+                loge("Error in NativeDaemonConnector: " + e);
+                SystemClock.sleep(5000);
+            }
+        }
+    }
+
+    @Override
+    public boolean handleMessage(Message msg) {
+        final String event = (String) msg.obj;
+        final int start = uptimeMillisInt();
+        final int sent = msg.arg1;
+        try {
+            if (!mCallbacks.onEvent(msg.what, event, NativeDaemonEvent.unescapeArgs(event))) {
+                log(String.format("Unhandled event '%s'", event));
+            }
+        } catch (Exception e) {
+            loge("Error handling '" + event + "': " + e);
+        } finally {
+            if (mCallbacks.onCheckHoldWakeLock(msg.what) && mWakeLock != null) {
+                mWakeLock.release();
+            }
+            final int end = uptimeMillisInt();
+            if (start > sent && start - sent > WARN_EXECUTE_DELAY_MS) {
+                loge(String.format("NDC event {%s} processed too late: %dms", event, start - sent));
+            }
+            if (end > start && end - start > WARN_EXECUTE_DELAY_MS) {
+                loge(String.format("NDC event {%s} took too long: %dms", event, end - start));
+            }
+        }
+        return true;
+    }
+
+    private LocalSocketAddress determineSocketAddress() {
+        // If we're testing, set up a socket in a namespace that's accessible to test code.
+        // In order to ensure that unprivileged apps aren't able to impersonate native daemons on
+        // production devices, even if said native daemons ill-advisedly pick a socket name that
+        // starts with __test__, only allow this on debug builds.
+        if (mSocket.startsWith("__test__") && Build.isDebuggable()) {
+            return new LocalSocketAddress(mSocket);
+        } else {
+            return new LocalSocketAddress(mSocket, LocalSocketAddress.Namespace.RESERVED);
+        }
+    }
+
+    private void listenToSocket() throws IOException {
+        LocalSocket socket = null;
+
+        try {
+            socket = new LocalSocket();
+            LocalSocketAddress address = determineSocketAddress();
+
+            socket.connect(address);
+
+            InputStream inputStream = socket.getInputStream();
+            synchronized (mDaemonLock) {
+                mOutputStream = socket.getOutputStream();
+            }
+
+            mCallbacks.onDaemonConnected();
+
+            FileDescriptor[] fdList = null;
+            byte[] buffer = new byte[BUFFER_SIZE];
+            int start = 0;
+
+            while (true) {
+                int count = inputStream.read(buffer, start, BUFFER_SIZE - start);
+                if (count < 0) {
+                    loge("got " + count + " reading with start = " + start);
+                    break;
+                }
+                fdList = socket.getAncillaryFileDescriptors();
+
+                // Add our starting point to the count and reset the start.
+                count += start;
+                start = 0;
+
+                for (int i = 0; i < count; i++) {
+                    if (buffer[i] == 0) {
+                        // Note - do not log this raw message since it may contain
+                        // sensitive data
+                        final String rawEvent = new String(
+                                buffer, start, i - start, StandardCharsets.UTF_8);
+
+                        boolean releaseWl = false;
+                        try {
+                            final NativeDaemonEvent event =
+                                    NativeDaemonEvent.parseRawEvent(rawEvent, fdList);
+
+                            log("RCV <- {" + event + "}");
+
+                            if (event.isClassUnsolicited()) {
+                                // TODO: migrate to sending NativeDaemonEvent instances
+                                if (mCallbacks.onCheckHoldWakeLock(event.getCode())
+                                        && mWakeLock != null) {
+                                    mWakeLock.acquire();
+                                    releaseWl = true;
+                                }
+                                Message msg = mCallbackHandler.obtainMessage(
+                                        event.getCode(), uptimeMillisInt(), 0, event.getRawEvent());
+                                if (mCallbackHandler.sendMessage(msg)) {
+                                    releaseWl = false;
+                                }
+                            } else {
+                                mResponseQueue.add(event.getCmdNumber(), event);
+                            }
+                        } catch (IllegalArgumentException e) {
+                            log("Problem parsing message " + e);
+                        } finally {
+                            if (releaseWl) {
+                                mWakeLock.release();
+                            }
+                        }
+
+                        start = i + 1;
+                    }
+                }
+
+                if (start == 0) {
+                    log("RCV incomplete");
+                }
+
+                // We should end at the amount we read. If not, compact then
+                // buffer and read again.
+                if (start != count) {
+                    final int remaining = BUFFER_SIZE - start;
+                    System.arraycopy(buffer, start, buffer, 0, remaining);
+                    start = remaining;
+                } else {
+                    start = 0;
+                }
+            }
+        } catch (IOException ex) {
+            loge("Communications error: " + ex);
+            throw ex;
+        } finally {
+            synchronized (mDaemonLock) {
+                if (mOutputStream != null) {
+                    try {
+                        loge("closing stream for " + mSocket);
+                        mOutputStream.close();
+                    } catch (IOException e) {
+                        loge("Failed closing output stream: " + e);
+                    }
+                    mOutputStream = null;
+                }
+            }
+
+            try {
+                if (socket != null) {
+                    socket.close();
+                }
+            } catch (IOException ex) {
+                loge("Failed closing socket: " + ex);
+            }
+        }
+    }
+
+    /**
+     * Wrapper around argument that indicates it's sensitive and shouldn't be
+     * logged.
+     */
+    public static class SensitiveArg {
+        private final Object mArg;
+
+        public SensitiveArg(Object arg) {
+            mArg = arg;
+        }
+
+        @Override
+        public String toString() {
+            return String.valueOf(mArg);
+        }
+    }
+
+    /**
+     * Make command for daemon, escaping arguments as needed.
+     */
+    @VisibleForTesting
+    static void makeCommand(StringBuilder rawBuilder, StringBuilder logBuilder, int sequenceNumber,
+            String cmd, Object... args) {
+        if (cmd.indexOf('\0') >= 0) {
+            throw new IllegalArgumentException("Unexpected command: " + cmd);
+        }
+        if (cmd.indexOf(' ') >= 0) {
+            throw new IllegalArgumentException("Arguments must be separate from command");
+        }
+
+        rawBuilder.append(sequenceNumber).append(' ').append(cmd);
+        logBuilder.append(sequenceNumber).append(' ').append(cmd);
+        for (Object arg : args) {
+            final String argString = String.valueOf(arg);
+            if (argString.indexOf('\0') >= 0) {
+                throw new IllegalArgumentException("Unexpected argument: " + arg);
+            }
+
+            rawBuilder.append(' ');
+            logBuilder.append(' ');
+
+            appendEscaped(rawBuilder, argString);
+            if (arg instanceof SensitiveArg) {
+                logBuilder.append("[scrubbed]");
+            } else {
+                appendEscaped(logBuilder, argString);
+            }
+        }
+
+        rawBuilder.append('\0');
+    }
+
+    /**
+     * Method that waits until all asychronous notifications sent by the native daemon have
+     * been processed. This method must not be called on the notification thread or an
+     * exception will be thrown.
+     */
+    public void waitForCallbacks() {
+        if (Thread.currentThread() == mLooper.getThread()) {
+            throw new IllegalStateException("Must not call this method on callback thread");
+        }
+
+        final CountDownLatch latch = new CountDownLatch(1);
+        mCallbackHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                latch.countDown();
+            }
+        });
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            Log.wtf(TAG, "Interrupted while waiting for unsolicited response handling", e);
+        }
+    }
+
+    /**
+     * Issue the given command to the native daemon and return a single expected
+     * response.
+     *
+     * @throws NativeDaemonConnectorException when problem communicating with
+     *             native daemon, or if the response matches
+     *             {@link NativeDaemonEvent#isClassClientError()} or
+     *             {@link NativeDaemonEvent#isClassServerError()}.
+     */
+    public NativeDaemonEvent execute(Command cmd) throws NativeDaemonConnectorException {
+        return execute(cmd.mCmd, cmd.mArguments.toArray());
+    }
+
+    /**
+     * Issue the given command to the native daemon and return a single expected
+     * response. Any arguments must be separated from base command so they can
+     * be properly escaped.
+     *
+     * @throws NativeDaemonConnectorException when problem communicating with
+     *             native daemon, or if the response matches
+     *             {@link NativeDaemonEvent#isClassClientError()} or
+     *             {@link NativeDaemonEvent#isClassServerError()}.
+     */
+    public NativeDaemonEvent execute(String cmd, Object... args)
+            throws NativeDaemonConnectorException {
+        return execute(DEFAULT_TIMEOUT, cmd, args);
+    }
+
+    public NativeDaemonEvent execute(long timeoutMs, String cmd, Object... args)
+            throws NativeDaemonConnectorException {
+        final NativeDaemonEvent[] events = executeForList(timeoutMs, cmd, args);
+        if (events.length != 1) {
+            throw new NativeDaemonConnectorException(
+                    "Expected exactly one response, but received " + events.length);
+        }
+        return events[0];
+    }
+
+    /**
+     * Issue the given command to the native daemon and return any
+     * {@link NativeDaemonEvent#isClassContinue()} responses, including the
+     * final terminal response.
+     *
+     * @throws NativeDaemonConnectorException when problem communicating with
+     *             native daemon, or if the response matches
+     *             {@link NativeDaemonEvent#isClassClientError()} or
+     *             {@link NativeDaemonEvent#isClassServerError()}.
+     */
+    public NativeDaemonEvent[] executeForList(Command cmd) throws NativeDaemonConnectorException {
+        return executeForList(cmd.mCmd, cmd.mArguments.toArray());
+    }
+
+    /**
+     * Issue the given command to the native daemon and return any
+     * {@link NativeDaemonEvent#isClassContinue()} responses, including the
+     * final terminal response. Any arguments must be separated from base
+     * command so they can be properly escaped.
+     *
+     * @throws NativeDaemonConnectorException when problem communicating with
+     *             native daemon, or if the response matches
+     *             {@link NativeDaemonEvent#isClassClientError()} or
+     *             {@link NativeDaemonEvent#isClassServerError()}.
+     */
+    public NativeDaemonEvent[] executeForList(String cmd, Object... args)
+            throws NativeDaemonConnectorException {
+        return executeForList(DEFAULT_TIMEOUT, cmd, args);
+    }
+
+    /**
+     * Issue the given command to the native daemon and return any {@linke
+     * NativeDaemonEvent@isClassContinue()} responses, including the final
+     * terminal response. Note that the timeout does not count time in deep
+     * sleep. Any arguments must be separated from base command so they can be
+     * properly escaped.
+     *
+     * @throws NativeDaemonConnectorException when problem communicating with
+     *             native daemon, or if the response matches
+     *             {@link NativeDaemonEvent#isClassClientError()} or
+     *             {@link NativeDaemonEvent#isClassServerError()}.
+     */
+    public NativeDaemonEvent[] executeForList(long timeoutMs, String cmd, Object... args)
+            throws NativeDaemonConnectorException {
+        if (mWarnIfHeld != null && Thread.holdsLock(mWarnIfHeld)) {
+            Log.wtf(TAG, "Calling thread " + Thread.currentThread().getName() + " is holding 0x"
+                    + Integer.toHexString(System.identityHashCode(mWarnIfHeld)), new Throwable());
+        }
+
+        final long startTime = SystemClock.elapsedRealtime();
+
+        final ArrayList<NativeDaemonEvent> events = new ArrayList<>();
+
+        final StringBuilder rawBuilder = new StringBuilder();
+        final StringBuilder logBuilder = new StringBuilder();
+        final int sequenceNumber = mSequenceNumber.incrementAndGet();
+
+        makeCommand(rawBuilder, logBuilder, sequenceNumber, cmd, args);
+
+        final String rawCmd = rawBuilder.toString();
+        final String logCmd = logBuilder.toString();
+
+        log("SND -> {" + logCmd + "}");
+
+        synchronized (mDaemonLock) {
+            if (mOutputStream == null) {
+                throw new NativeDaemonConnectorException("missing output stream");
+            } else {
+                try {
+                    mOutputStream.write(rawCmd.getBytes(StandardCharsets.UTF_8));
+                } catch (IOException e) {
+                    throw new NativeDaemonConnectorException("problem sending command", e);
+                }
+            }
+        }
+
+        NativeDaemonEvent event = null;
+        do {
+            event = mResponseQueue.remove(sequenceNumber, timeoutMs, logCmd);
+            if (event == null) {
+                loge("timed-out waiting for response to " + logCmd);
+                throw new NativeDaemonTimeoutException(logCmd, event);
+            }
+            if (VDBG) log("RMV <- {" + event + "}");
+            events.add(event);
+        } while (event.isClassContinue());
+
+        final long endTime = SystemClock.elapsedRealtime();
+        if (endTime - startTime > WARN_EXECUTE_DELAY_MS) {
+            loge("NDC Command {" + logCmd + "} took too long (" + (endTime - startTime) + "ms)");
+        }
+
+        if (event.isClassClientError()) {
+            throw new NativeDaemonArgumentException(logCmd, event);
+        }
+        if (event.isClassServerError()) {
+            throw new NativeDaemonFailureException(logCmd, event);
+        }
+
+        return events.toArray(new NativeDaemonEvent[events.size()]);
+    }
+
+    /**
+     * Append the given argument to {@link StringBuilder}, escaping as needed,
+     * and surrounding with quotes when it contains spaces.
+     */
+    @VisibleForTesting
+    static void appendEscaped(StringBuilder builder, String arg) {
+        final boolean hasSpaces = arg.indexOf(' ') >= 0;
+        if (hasSpaces) {
+            builder.append('"');
+        }
+
+        final int length = arg.length();
+        for (int i = 0; i < length; i++) {
+            final char c = arg.charAt(i);
+
+            if (c == '"') {
+                builder.append("\\\"");
+            } else if (c == '\\') {
+                builder.append("\\\\");
+            } else {
+                builder.append(c);
+            }
+        }
+
+        if (hasSpaces) {
+            builder.append('"');
+        }
+    }
+
+    private static class NativeDaemonArgumentException extends NativeDaemonConnectorException {
+        public NativeDaemonArgumentException(String command, NativeDaemonEvent event) {
+            super(command, event);
+        }
+
+        @Override
+        public IllegalArgumentException rethrowAsParcelableException() {
+            throw new IllegalArgumentException(getMessage(), this);
+        }
+    }
+
+    private static class NativeDaemonFailureException extends NativeDaemonConnectorException {
+        public NativeDaemonFailureException(String command, NativeDaemonEvent event) {
+            super(command, event);
+        }
+    }
+
+    /**
+     * Command builder that handles argument list building. Any arguments must
+     * be separated from base command so they can be properly escaped.
+     */
+    public static class Command {
+        private String mCmd;
+        private ArrayList<Object> mArguments = new ArrayList<>();
+
+        public Command(String cmd, Object... args) {
+            mCmd = cmd;
+            for (Object arg : args) {
+                appendArg(arg);
+            }
+        }
+
+        public Command appendArg(Object arg) {
+            mArguments.add(arg);
+            return this;
+        }
+    }
+
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        mLocalLog.dump(fd, pw, args);
+        pw.println();
+        mResponseQueue.dump(fd, pw, args);
+    }
+
+    private void log(String logstring) {
+        if (mDebug) Log.d(TAG, logstring);
+        mLocalLog.log(logstring);
+    }
+
+    private void loge(String logstring) {
+        Log.e(TAG, logstring);
+        mLocalLog.log(logstring);
+    }
+
+    private static class ResponseQueue {
+
+        private static class PendingCmd {
+            public final int cmdNum;
+            public final String logCmd;
+
+            public BlockingQueue<NativeDaemonEvent> responses =
+                    new ArrayBlockingQueue<NativeDaemonEvent>(10);
+
+            // The availableResponseCount member is used to track when we can remove this
+            // instance from the ResponseQueue.
+            // This is used under the protection of a sync of the mPendingCmds object.
+            // A positive value means we've had more writers retreive this object while
+            // a negative value means we've had more readers.  When we've had an equal number
+            // (it goes to zero) we can remove this object from the mPendingCmds list.
+            // Note that we may have more responses for this command (and more readers
+            // coming), but that would result in a new PendingCmd instance being created
+            // and added with the same cmdNum.
+            // Also note that when this goes to zero it just means a parity of readers and
+            // writers have retrieved this object - not that they are done using it.  The
+            // responses queue may well have more responses yet to be read or may get more
+            // responses added to it.  But all those readers/writers have retreived and
+            // hold references to this instance already so it can be removed from
+            // mPendingCmds queue.
+            public int availableResponseCount;
+
+            public PendingCmd(int cmdNum, String logCmd) {
+                this.cmdNum = cmdNum;
+                this.logCmd = logCmd;
+            }
+        }
+
+        private final LinkedList<PendingCmd> mPendingCmds;
+        private int mMaxCount;
+
+        ResponseQueue(int maxCount) {
+            mPendingCmds = new LinkedList<PendingCmd>();
+            mMaxCount = maxCount;
+        }
+
+        public void add(int cmdNum, NativeDaemonEvent response) {
+            PendingCmd found = null;
+            synchronized (mPendingCmds) {
+                for (PendingCmd pendingCmd : mPendingCmds) {
+                    if (pendingCmd.cmdNum == cmdNum) {
+                        found = pendingCmd;
+                        break;
+                    }
+                }
+                if (found == null) {
+                    // didn't find it - make sure our queue isn't too big before adding
+                    while (mPendingCmds.size() >= mMaxCount) {
+                        Log.e("NativeDaemonConnector.ResponseQueue",
+                                "more buffered than allowed: " + mPendingCmds.size() +
+                                " >= " + mMaxCount);
+                        // let any waiter timeout waiting for this
+                        PendingCmd pendingCmd = mPendingCmds.remove();
+                        Log.e("NativeDaemonConnector.ResponseQueue",
+                                "Removing request: " + pendingCmd.logCmd + " (" +
+                                pendingCmd.cmdNum + ")");
+                    }
+                    found = new PendingCmd(cmdNum, null);
+                    mPendingCmds.add(found);
+                }
+                found.availableResponseCount++;
+                // if a matching remove call has already retrieved this we can remove this
+                // instance from our list
+                if (found.availableResponseCount == 0) mPendingCmds.remove(found);
+            }
+            try {
+                found.responses.put(response);
+            } catch (InterruptedException e) { }
+        }
+
+        // note that the timeout does not count time in deep sleep.  If you don't want
+        // the device to sleep, hold a wakelock
+        public NativeDaemonEvent remove(int cmdNum, long timeoutMs, String logCmd) {
+            PendingCmd found = null;
+            synchronized (mPendingCmds) {
+                for (PendingCmd pendingCmd : mPendingCmds) {
+                    if (pendingCmd.cmdNum == cmdNum) {
+                        found = pendingCmd;
+                        break;
+                    }
+                }
+                if (found == null) {
+                    found = new PendingCmd(cmdNum, logCmd);
+                    mPendingCmds.add(found);
+                }
+                found.availableResponseCount--;
+                // if a matching add call has already retrieved this we can remove this
+                // instance from our list
+                if (found.availableResponseCount == 0) mPendingCmds.remove(found);
+            }
+            NativeDaemonEvent result = null;
+            try {
+                result = found.responses.poll(timeoutMs, TimeUnit.MILLISECONDS);
+            } catch (InterruptedException e) {}
+            if (result == null) {
+                Log.e("NativeDaemonConnector.ResponseQueue", "Timeout waiting for response");
+            }
+            return result;
+        }
+
+        public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+            pw.println("Pending requests:");
+            synchronized (mPendingCmds) {
+                for (PendingCmd pendingCmd : mPendingCmds) {
+                    pw.println("  Cmd " + pendingCmd.cmdNum + " - " + pendingCmd.logCmd);
+                }
+            }
+        }
+    }
+}
diff --git a/service-t/src/com/android/server/NativeDaemonConnectorException.java b/service-t/src/com/android/server/NativeDaemonConnectorException.java
new file mode 100644
index 0000000..4d8881c
--- /dev/null
+++ b/service-t/src/com/android/server/NativeDaemonConnectorException.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2006 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.Parcel;
+
+/**
+ * An exception that indicates there was an error with a
+ * {@link NativeDaemonConnector} operation.
+ */
+public class NativeDaemonConnectorException extends Exception {
+    private String mCmd;
+    private NativeDaemonEvent mEvent;
+
+    public NativeDaemonConnectorException(String detailMessage) {
+        super(detailMessage);
+    }
+
+    public NativeDaemonConnectorException(String detailMessage, Throwable throwable) {
+        super(detailMessage, throwable);
+    }
+
+    public NativeDaemonConnectorException(String cmd, NativeDaemonEvent event) {
+        super("command '" + cmd + "' failed with '" + event + "'");
+        mCmd = cmd;
+        mEvent = event;
+    }
+
+    public int getCode() {
+        return mEvent != null ? mEvent.getCode() : -1;
+    }
+
+    public String getCmd() {
+        return mCmd;
+    }
+
+    /**
+     * Rethrow as a {@link RuntimeException} subclass that is handled by
+     * {@link Parcel#writeException(Exception)}.
+     */
+    public IllegalArgumentException rethrowAsParcelableException() {
+        throw new IllegalStateException(getMessage(), this);
+    }
+}
diff --git a/service-t/src/com/android/server/NativeDaemonEvent.java b/service-t/src/com/android/server/NativeDaemonEvent.java
new file mode 100644
index 0000000..5683694
--- /dev/null
+++ b/service-t/src/com/android/server/NativeDaemonEvent.java
@@ -0,0 +1,267 @@
+/*
+ * Copyright (C) 2011 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.util.Log;
+
+import java.io.FileDescriptor;
+import java.util.ArrayList;
+
+/**
+ * Parsed event from native side of {@link NativeDaemonConnector}.
+ */
+public class NativeDaemonEvent {
+
+    // TODO: keep class ranges in sync with ResponseCode.h
+    // TODO: swap client and server error ranges to roughly mirror HTTP spec
+
+    private final int mCmdNumber;
+    private final int mCode;
+    private final String mMessage;
+    private final String mRawEvent;
+    private final String mLogMessage;
+    private String[] mParsed;
+    private FileDescriptor[] mFdList;
+
+    private NativeDaemonEvent(int cmdNumber, int code, String message,
+                              String rawEvent, String logMessage, FileDescriptor[] fdList) {
+        mCmdNumber = cmdNumber;
+        mCode = code;
+        mMessage = message;
+        mRawEvent = rawEvent;
+        mLogMessage = logMessage;
+        mParsed = null;
+        mFdList = fdList;
+    }
+
+    static public final String SENSITIVE_MARKER = "{{sensitive}}";
+
+    public int getCmdNumber() {
+        return mCmdNumber;
+    }
+
+    public int getCode() {
+        return mCode;
+    }
+
+    public String getMessage() {
+        return mMessage;
+    }
+
+    public FileDescriptor[] getFileDescriptors() {
+        return mFdList;
+    }
+
+    @Deprecated
+    public String getRawEvent() {
+        return mRawEvent;
+    }
+
+    @Override
+    public String toString() {
+        return mLogMessage;
+    }
+
+    /**
+     * Test if event represents a partial response which is continued in
+     * additional subsequent events.
+     */
+    public boolean isClassContinue() {
+        return mCode >= 100 && mCode < 200;
+    }
+
+    /**
+     * Test if event represents a command success.
+     */
+    public boolean isClassOk() {
+        return mCode >= 200 && mCode < 300;
+    }
+
+    /**
+     * Test if event represents a remote native daemon error.
+     */
+    public boolean isClassServerError() {
+        return mCode >= 400 && mCode < 500;
+    }
+
+    /**
+     * Test if event represents a command syntax or argument error.
+     */
+    public boolean isClassClientError() {
+        return mCode >= 500 && mCode < 600;
+    }
+
+    /**
+     * Test if event represents an unsolicited event from native daemon.
+     */
+    public boolean isClassUnsolicited() {
+        return isClassUnsolicited(mCode);
+    }
+
+    private static boolean isClassUnsolicited(int code) {
+        return code >= 600 && code < 700;
+    }
+
+    /**
+     * Verify this event matches the given code.
+     *
+     * @throws IllegalStateException if {@link #getCode()} doesn't match.
+     */
+    public void checkCode(int code) {
+        if (mCode != code) {
+            throw new IllegalStateException("Expected " + code + " but was: " + this);
+        }
+    }
+
+    /**
+     * Parse the given raw event into {@link NativeDaemonEvent} instance.
+     *
+     * @throws IllegalArgumentException when line doesn't match format expected
+     *             from native side.
+     */
+    public static NativeDaemonEvent parseRawEvent(String rawEvent, FileDescriptor[] fdList) {
+        final String[] parsed = rawEvent.split(" ");
+        if (parsed.length < 2) {
+            throw new IllegalArgumentException("Insufficient arguments");
+        }
+
+        int skiplength = 0;
+
+        final int code;
+        try {
+            code = Integer.parseInt(parsed[0]);
+            skiplength = parsed[0].length() + 1;
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("problem parsing code", e);
+        }
+
+        int cmdNumber = -1;
+        if (isClassUnsolicited(code) == false) {
+            if (parsed.length < 3) {
+                throw new IllegalArgumentException("Insufficient arguemnts");
+            }
+            try {
+                cmdNumber = Integer.parseInt(parsed[1]);
+                skiplength += parsed[1].length() + 1;
+            } catch (NumberFormatException e) {
+                throw new IllegalArgumentException("problem parsing cmdNumber", e);
+            }
+        }
+
+        String logMessage = rawEvent;
+        if (parsed.length > 2 && parsed[2].equals(SENSITIVE_MARKER)) {
+            skiplength += parsed[2].length() + 1;
+            logMessage = parsed[0] + " " + parsed[1] + " {}";
+        }
+
+        final String message = rawEvent.substring(skiplength);
+
+        return new NativeDaemonEvent(cmdNumber, code, message, rawEvent, logMessage, fdList);
+    }
+
+    /**
+     * Filter the given {@link NativeDaemonEvent} list, returning
+     * {@link #getMessage()} for any events matching the requested code.
+     */
+    public static String[] filterMessageList(NativeDaemonEvent[] events, int matchCode) {
+        final ArrayList<String> result = new ArrayList<>();
+        for (NativeDaemonEvent event : events) {
+            if (event.getCode() == matchCode) {
+                result.add(event.getMessage());
+            }
+        }
+        return result.toArray(new String[result.size()]);
+    }
+
+    /**
+     * Find the Nth field of the event.
+     *
+     * This ignores and code or cmdNum, the first return value is given for N=0.
+     * Also understands "\"quoted\" multiword responses" and tries them as a single field
+     */
+    public String getField(int n) {
+        if (mParsed == null) {
+            mParsed = unescapeArgs(mRawEvent);
+        }
+        n += 2; // skip code and command#
+        if (n > mParsed.length) return null;
+            return mParsed[n];
+        }
+
+    public static String[] unescapeArgs(String rawEvent) {
+        final boolean DEBUG_ROUTINE = false;
+        final String LOGTAG = "unescapeArgs";
+        final ArrayList<String> parsed = new ArrayList<String>();
+        final int length = rawEvent.length();
+        int current = 0;
+        int wordEnd = -1;
+        boolean quoted = false;
+
+        if (DEBUG_ROUTINE) Log.e(LOGTAG, "parsing '" + rawEvent + "'");
+        if (rawEvent.charAt(current) == '\"') {
+            quoted = true;
+            current++;
+        }
+        while (current < length) {
+            // find the end of the word
+            char terminator = quoted ? '\"' : ' ';
+            wordEnd = current;
+            while (wordEnd < length && rawEvent.charAt(wordEnd) != terminator) {
+                if (rawEvent.charAt(wordEnd) == '\\') {
+                    // skip the escaped char
+                    ++wordEnd;
+                }
+                ++wordEnd;
+            }
+            if (wordEnd > length) wordEnd = length;
+            String word = rawEvent.substring(current, wordEnd);
+            current += word.length();
+            if (!quoted) {
+                word = word.trim();
+            } else {
+                current++;  // skip the trailing quote
+            }
+            // unescape stuff within the word
+            word = word.replace("\\\\", "\\");
+            word = word.replace("\\\"", "\"");
+
+            if (DEBUG_ROUTINE) Log.e(LOGTAG, "found '" + word + "'");
+            parsed.add(word);
+
+            // find the beginning of the next word - either of these options
+            int nextSpace = rawEvent.indexOf(' ', current);
+            int nextQuote = rawEvent.indexOf(" \"", current);
+            if (DEBUG_ROUTINE) {
+                Log.e(LOGTAG, "nextSpace=" + nextSpace + ", nextQuote=" + nextQuote);
+            }
+            if (nextQuote > -1 && nextQuote <= nextSpace) {
+                quoted = true;
+                current = nextQuote + 2;
+            } else {
+                quoted = false;
+                if (nextSpace > -1) {
+                    current = nextSpace + 1;
+                }
+            } // else we just start the next word after the current and read til the end
+            if (DEBUG_ROUTINE) {
+                Log.e(LOGTAG, "next loop - current=" + current
+                        + ", length=" + length + ", quoted=" + quoted);
+            }
+        }
+        return parsed.toArray(new String[parsed.size()]);
+    }
+}
diff --git a/service-t/src/com/android/server/NativeDaemonTimeoutException.java b/service-t/src/com/android/server/NativeDaemonTimeoutException.java
new file mode 100644
index 0000000..658f7d6
--- /dev/null
+++ b/service-t/src/com/android/server/NativeDaemonTimeoutException.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2015 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;
+
+/**
+ * An exception that indicates there was a timeout with a
+ * {@link NativeDaemonConnector} operation.
+ */
+public class NativeDaemonTimeoutException extends NativeDaemonConnectorException {
+    public NativeDaemonTimeoutException(String command, NativeDaemonEvent event) {
+        super(command, event);
+    }
+}
+
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
new file mode 100644
index 0000000..ddf6d2c
--- /dev/null
+++ b/service-t/src/com/android/server/NsdService.java
@@ -0,0 +1,1146 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.nsd.INsdManager;
+import android.net.nsd.INsdManagerCallback;
+import android.net.nsd.INsdServiceConnector;
+import android.net.nsd.NsdManager;
+import android.net.nsd.NsdServiceInfo;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.Message;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.util.Base64;
+import android.util.Log;
+import android.util.Pair;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.State;
+import com.android.internal.util.StateMachine;
+import com.android.net.module.util.DnsSdTxtRecord;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * Network Service Discovery Service handles remote service discovery operation requests by
+ * implementing the INsdManager interface.
+ *
+ * @hide
+ */
+public class NsdService extends INsdManager.Stub {
+    private static final String TAG = "NsdService";
+    private static final String MDNS_TAG = "mDnsConnector";
+
+    private static final boolean DBG = true;
+    private static final long CLEANUP_DELAY_MS = 10000;
+    private static final int IFACE_IDX_ANY = 0;
+
+    private final Context mContext;
+    private final NsdStateMachine mNsdStateMachine;
+    private final DaemonConnection mDaemon;
+    private final NativeCallbackReceiver mDaemonCallback;
+
+    /**
+     * Clients receiving asynchronous messages
+     */
+    private final HashMap<NsdServiceConnector, ClientInfo> mClients = new HashMap<>();
+
+    /* A map from unique id to client info */
+    private final SparseArray<ClientInfo> mIdToClientInfoMap= new SparseArray<>();
+
+    private final long mCleanupDelayMs;
+
+    private static final int INVALID_ID = 0;
+    private int mUniqueId = 1;
+    // The count of the connected legacy clients.
+    private int mLegacyClientCount = 0;
+
+    private class NsdStateMachine extends StateMachine {
+
+        private final DefaultState mDefaultState = new DefaultState();
+        private final DisabledState mDisabledState = new DisabledState();
+        private final EnabledState mEnabledState = new EnabledState();
+
+        @Override
+        protected String getWhatToString(int what) {
+            return NsdManager.nameOf(what);
+        }
+
+        private void maybeStartDaemon() {
+            mDaemon.maybeStart();
+            maybeScheduleStop();
+        }
+
+        private boolean isAnyRequestActive() {
+            return mIdToClientInfoMap.size() != 0;
+        }
+
+        private void scheduleStop() {
+            sendMessageDelayed(NsdManager.DAEMON_CLEANUP, mCleanupDelayMs);
+        }
+        private void maybeScheduleStop() {
+            // The native daemon should stay alive and can't be cleanup
+            // if any legacy client connected.
+            if (!isAnyRequestActive() && mLegacyClientCount == 0) {
+                scheduleStop();
+            }
+        }
+
+        private void cancelStop() {
+            this.removeMessages(NsdManager.DAEMON_CLEANUP);
+        }
+
+        NsdStateMachine(String name, Handler handler) {
+            super(name, handler);
+            addState(mDefaultState);
+                addState(mDisabledState, mDefaultState);
+                addState(mEnabledState, mDefaultState);
+            State initialState = mEnabledState;
+            setInitialState(initialState);
+            setLogRecSize(25);
+        }
+
+        class DefaultState extends State {
+            @Override
+            public boolean processMessage(Message msg) {
+                final ClientInfo cInfo;
+                final int clientId = msg.arg2;
+                switch (msg.what) {
+                    case NsdManager.REGISTER_CLIENT:
+                        final Pair<NsdServiceConnector, INsdManagerCallback> arg =
+                                (Pair<NsdServiceConnector, INsdManagerCallback>) msg.obj;
+                        final INsdManagerCallback cb = arg.second;
+                        try {
+                            cb.asBinder().linkToDeath(arg.first, 0);
+                            cInfo = new ClientInfo(cb);
+                            mClients.put(arg.first, cInfo);
+                        } catch (RemoteException e) {
+                            Log.w(TAG, "Client " + clientId + " 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.isLegacy()) {
+                                mLegacyClientCount -= 1;
+                            }
+                        }
+                        maybeScheduleStop();
+                        break;
+                    case NsdManager.DISCOVER_SERVICES:
+                        cInfo = getClientInfoForReply(msg);
+                        if (cInfo != null) {
+                            cInfo.onDiscoverServicesFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                       break;
+                    case NsdManager.STOP_DISCOVERY:
+                        cInfo = getClientInfoForReply(msg);
+                        if (cInfo != null) {
+                            cInfo.onStopDiscoveryFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.REGISTER_SERVICE:
+                        cInfo = getClientInfoForReply(msg);
+                        if (cInfo != null) {
+                            cInfo.onRegisterServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.UNREGISTER_SERVICE:
+                        cInfo = getClientInfoForReply(msg);
+                        if (cInfo != null) {
+                            cInfo.onUnregisterServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.RESOLVE_SERVICE:
+                        cInfo = getClientInfoForReply(msg);
+                        if (cInfo != null) {
+                            cInfo.onResolveServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.DAEMON_CLEANUP:
+                        mDaemon.maybeStop();
+                        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.setLegacy();
+                            mLegacyClientCount += 1;
+                            maybeStartDaemon();
+                        }
+                        break;
+                    case NsdManager.NATIVE_DAEMON_EVENT:
+                    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 DisabledState extends State {
+            @Override
+            public void enter() {
+                sendNsdStateChangeBroadcast(false);
+            }
+
+            @Override
+            public boolean processMessage(Message msg) {
+                switch (msg.what) {
+                    case NsdManager.ENABLE:
+                        transitionTo(mEnabledState);
+                        break;
+                    default:
+                        return NOT_HANDLED;
+                }
+                return HANDLED;
+            }
+        }
+
+        class EnabledState extends State {
+            @Override
+            public void enter() {
+                sendNsdStateChangeBroadcast(true);
+            }
+
+            @Override
+            public void exit() {
+                // TODO: it is incorrect to stop the daemon without expunging all requests
+                // and sending error callbacks to clients.
+                scheduleStop();
+            }
+
+            private boolean requestLimitReached(ClientInfo clientInfo) {
+                if (clientInfo.mClientIds.size() >= ClientInfo.MAX_LIMIT) {
+                    if (DBG) Log.d(TAG, "Exceeded max outstanding requests " + clientInfo);
+                    return true;
+                }
+                return false;
+            }
+
+            private void storeRequestMap(int clientId, int globalId, ClientInfo clientInfo, int what) {
+                clientInfo.mClientIds.put(clientId, globalId);
+                clientInfo.mClientRequests.put(clientId, what);
+                mIdToClientInfoMap.put(globalId, clientInfo);
+                // Remove the cleanup event because here comes a new request.
+                cancelStop();
+            }
+
+            private void removeRequestMap(int clientId, int globalId, ClientInfo clientInfo) {
+                clientInfo.mClientIds.delete(clientId);
+                clientInfo.mClientRequests.delete(clientId);
+                mIdToClientInfoMap.remove(globalId);
+                maybeScheduleStop();
+            }
+
+            @Override
+            public boolean processMessage(Message msg) {
+                final ClientInfo clientInfo;
+                final int id;
+                final int clientId = msg.arg2;
+                final ListenerArgs args;
+                switch (msg.what) {
+                    case NsdManager.DISABLE:
+                        //TODO: cleanup clients
+                        transitionTo(mDisabledState);
+                        break;
+                    case NsdManager.DISCOVER_SERVICES:
+                        if (DBG) Log.d(TAG, "Discover services");
+                        args = (ListenerArgs) msg.obj;
+                        clientInfo = mClients.get(args.connector);
+
+                        if (requestLimitReached(clientInfo)) {
+                            clientInfo.onDiscoverServicesFailed(
+                                    clientId, NsdManager.FAILURE_MAX_LIMIT);
+                            break;
+                        }
+
+                        maybeStartDaemon();
+                        id = getUniqueId();
+                        if (discoverServices(id, args.serviceInfo)) {
+                            if (DBG) {
+                                Log.d(TAG, "Discover " + msg.arg2 + " " + id
+                                        + args.serviceInfo.getServiceType());
+                            }
+                            storeRequestMap(clientId, id, clientInfo, msg.what);
+                            clientInfo.onDiscoverServicesStarted(clientId, args.serviceInfo);
+                        } else {
+                            stopServiceDiscovery(id);
+                            clientInfo.onDiscoverServicesFailed(clientId,
+                                    NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.STOP_DISCOVERY:
+                        if (DBG) Log.d(TAG, "Stop service discovery");
+                        args = (ListenerArgs) msg.obj;
+                        clientInfo = mClients.get(args.connector);
+
+                        try {
+                            id = clientInfo.mClientIds.get(clientId);
+                        } catch (NullPointerException e) {
+                            clientInfo.onStopDiscoveryFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                            break;
+                        }
+                        removeRequestMap(clientId, id, clientInfo);
+                        if (stopServiceDiscovery(id)) {
+                            clientInfo.onStopDiscoverySucceeded(clientId);
+                        } else {
+                            clientInfo.onStopDiscoveryFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.REGISTER_SERVICE:
+                        if (DBG) Log.d(TAG, "Register service");
+                        args = (ListenerArgs) msg.obj;
+                        clientInfo = mClients.get(args.connector);
+                        if (requestLimitReached(clientInfo)) {
+                            clientInfo.onRegisterServiceFailed(
+                                    clientId, NsdManager.FAILURE_MAX_LIMIT);
+                            break;
+                        }
+
+                        maybeStartDaemon();
+                        id = getUniqueId();
+                        if (registerService(id, args.serviceInfo)) {
+                            if (DBG) Log.d(TAG, "Register " + clientId + " " + id);
+                            storeRequestMap(clientId, id, clientInfo, msg.what);
+                            // Return success after mDns reports success
+                        } else {
+                            unregisterService(id);
+                            clientInfo.onRegisterServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.UNREGISTER_SERVICE:
+                        if (DBG) Log.d(TAG, "unregister service");
+                        args = (ListenerArgs) msg.obj;
+                        clientInfo = mClients.get(args.connector);
+                        if (clientInfo == null) {
+                            Log.e(TAG, "Unknown connector in unregistration");
+                            break;
+                        }
+                        id = clientInfo.mClientIds.get(clientId);
+                        removeRequestMap(clientId, id, clientInfo);
+                        if (unregisterService(id)) {
+                            clientInfo.onUnregisterServiceSucceeded(clientId);
+                        } else {
+                            clientInfo.onUnregisterServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.RESOLVE_SERVICE:
+                        if (DBG) Log.d(TAG, "Resolve service");
+                        args = (ListenerArgs) msg.obj;
+                        clientInfo = mClients.get(args.connector);
+
+                        if (clientInfo.mResolvedService != null) {
+                            clientInfo.onResolveServiceFailed(
+                                    clientId, NsdManager.FAILURE_ALREADY_ACTIVE);
+                            break;
+                        }
+
+                        maybeStartDaemon();
+                        id = getUniqueId();
+                        if (resolveService(id, args.serviceInfo)) {
+                            clientInfo.mResolvedService = new NsdServiceInfo();
+                            storeRequestMap(clientId, id, clientInfo, msg.what);
+                        } else {
+                            clientInfo.onResolveServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        break;
+                    case NsdManager.NATIVE_DAEMON_EVENT:
+                        NativeEvent event = (NativeEvent) msg.obj;
+                        if (!handleNativeEvent(event.code, event.raw, event.cooked)) {
+                            return NOT_HANDLED;
+                        }
+                        break;
+                    default:
+                        return NOT_HANDLED;
+                }
+                return HANDLED;
+            }
+
+            private boolean handleNativeEvent(int code, String raw, String[] cooked) {
+                NsdServiceInfo servInfo;
+                int id = Integer.parseInt(cooked[1]);
+                ClientInfo clientInfo = mIdToClientInfoMap.get(id);
+                if (clientInfo == null) {
+                    String name = NativeResponseCode.nameOf(code);
+                    Log.e(TAG, String.format("id %d for %s has no client mapping", id, name));
+                    return false;
+                }
+
+                /* This goes in response as msg.arg2 */
+                int clientId = clientInfo.getClientId(id);
+                if (clientId < 0) {
+                    // This can happen because of race conditions. For example,
+                    // SERVICE_FOUND may race with STOP_SERVICE_DISCOVERY,
+                    // and we may get in this situation.
+                    String name = NativeResponseCode.nameOf(code);
+                    Log.d(TAG, String.format(
+                            "Notification %s for listener id %d that is no longer active",
+                            name, id));
+                    return false;
+                }
+                if (DBG) {
+                    String name = NativeResponseCode.nameOf(code);
+                    Log.d(TAG, String.format("Native daemon message %s: %s", name, raw));
+                }
+                switch (code) {
+                    case NativeResponseCode.SERVICE_FOUND:
+                        /* NNN uniqueId serviceName regType domain interfaceIdx netId */
+                        servInfo = new NsdServiceInfo(cooked[2], cooked[3]);
+                        final int foundNetId;
+                        try {
+                            foundNetId = Integer.parseInt(cooked[6]);
+                        } catch (NumberFormatException e) {
+                            Log.wtf(TAG, "Invalid network received from mdnsd: " + cooked[6]);
+                            break;
+                        }
+                        if (foundNetId == 0L) {
+                            // Ignore services that do not have a Network: they are not usable
+                            // by apps, as they would need privileged permissions to use
+                            // interfaces that do not have an associated Network.
+                            break;
+                        }
+                        servInfo.setNetwork(new Network(foundNetId));
+                        clientInfo.onServiceFound(clientId, servInfo);
+                        break;
+                    case NativeResponseCode.SERVICE_LOST:
+                        /* NNN uniqueId serviceName regType domain interfaceIdx netId */
+                        final int lostNetId;
+                        try {
+                            lostNetId = Integer.parseInt(cooked[6]);
+                        } catch (NumberFormatException e) {
+                            Log.wtf(TAG, "Invalid network received from mdnsd: " + cooked[6]);
+                            break;
+                        }
+                        servInfo = new NsdServiceInfo(cooked[2], cooked[3]);
+                        // The network could be null if it was torn down when the service is lost
+                        // TODO: avoid returning null in that case, possibly by remembering found
+                        // services on the same interface index and their network at the time
+                        servInfo.setNetwork(lostNetId == 0 ? null : new Network(lostNetId));
+                        clientInfo.onServiceLost(clientId, servInfo);
+                        break;
+                    case NativeResponseCode.SERVICE_DISCOVERY_FAILED:
+                        /* NNN uniqueId errorCode */
+                        clientInfo.onDiscoverServicesFailed(
+                                clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        break;
+                    case NativeResponseCode.SERVICE_REGISTERED:
+                        /* NNN regId serviceName regType */
+                        servInfo = new NsdServiceInfo(cooked[2], null);
+                        clientInfo.onRegisterServiceSucceeded(clientId, servInfo);
+                        break;
+                    case NativeResponseCode.SERVICE_REGISTRATION_FAILED:
+                        /* NNN regId errorCode */
+                        clientInfo.onRegisterServiceFailed(
+                                clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        break;
+                    case NativeResponseCode.SERVICE_UPDATED:
+                        /* NNN regId */
+                        break;
+                    case NativeResponseCode.SERVICE_UPDATE_FAILED:
+                        /* NNN regId errorCode */
+                        break;
+                    case NativeResponseCode.SERVICE_RESOLVED:
+                        /* NNN resolveId fullName hostName port txtlen txtdata interfaceIdx */
+                        int index = 0;
+                        while (index < cooked[2].length() && cooked[2].charAt(index) != '.') {
+                            if (cooked[2].charAt(index) == '\\') {
+                                ++index;
+                            }
+                            ++index;
+                        }
+                        if (index >= cooked[2].length()) {
+                            Log.e(TAG, "Invalid service found " + raw);
+                            break;
+                        }
+
+                        String name = cooked[2].substring(0, index);
+                        String rest = cooked[2].substring(index);
+                        String type = rest.replace(".local.", "");
+
+                        name = unescape(name);
+
+                        clientInfo.mResolvedService.setServiceName(name);
+                        clientInfo.mResolvedService.setServiceType(type);
+                        clientInfo.mResolvedService.setPort(Integer.parseInt(cooked[4]));
+                        clientInfo.mResolvedService.setTxtRecords(cooked[6]);
+                        // Network will be added after SERVICE_GET_ADDR_SUCCESS
+
+                        stopResolveService(id);
+                        removeRequestMap(clientId, id, clientInfo);
+
+                        int id2 = getUniqueId();
+                        if (getAddrInfo(id2, cooked[3], cooked[7] /* interfaceIdx */)) {
+                            storeRequestMap(clientId, id2, clientInfo, NsdManager.RESOLVE_SERVICE);
+                        } else {
+                            clientInfo.onResolveServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                            clientInfo.mResolvedService = null;
+                        }
+                        break;
+                    case NativeResponseCode.SERVICE_RESOLUTION_FAILED:
+                        /* NNN resolveId errorCode */
+                        stopResolveService(id);
+                        removeRequestMap(clientId, id, clientInfo);
+                        clientInfo.mResolvedService = null;
+                        clientInfo.onResolveServiceFailed(
+                                clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        break;
+                    case NativeResponseCode.SERVICE_GET_ADDR_FAILED:
+                        /* NNN resolveId errorCode */
+                        stopGetAddrInfo(id);
+                        removeRequestMap(clientId, id, clientInfo);
+                        clientInfo.mResolvedService = null;
+                        clientInfo.onResolveServiceFailed(
+                                clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        break;
+                    case NativeResponseCode.SERVICE_GET_ADDR_SUCCESS:
+                        /* NNN resolveId hostname ttl addr interfaceIdx netId */
+                        Network network = null;
+                        try {
+                            final int netId = Integer.parseInt(cooked[6]);
+                            network = netId == 0L ? null : new Network(netId);
+                        } catch (NumberFormatException e) {
+                            Log.wtf(TAG, "Invalid network in GET_ADDR_SUCCESS: " + cooked[6], e);
+                        }
+
+                        InetAddress serviceHost = null;
+                        try {
+                            serviceHost = InetAddress.getByName(cooked[4]);
+                        } catch (UnknownHostException e) {
+                            Log.wtf(TAG, "Invalid host in GET_ADDR_SUCCESS", e);
+                        }
+
+                        // If the resolved service is on an interface without a network, consider it
+                        // as a failure: it would not be usable by apps as they would need
+                        // privileged permissions.
+                        if (network != null && serviceHost != null) {
+                            clientInfo.mResolvedService.setHost(serviceHost);
+                            clientInfo.mResolvedService.setNetwork(network);
+                            clientInfo.onResolveServiceSucceeded(
+                                    clientId, clientInfo.mResolvedService);
+                        } else {
+                            clientInfo.onResolveServiceFailed(
+                                    clientId, NsdManager.FAILURE_INTERNAL_ERROR);
+                        }
+                        stopGetAddrInfo(id);
+                        removeRequestMap(clientId, id, clientInfo);
+                        clientInfo.mResolvedService = null;
+                        break;
+                    default:
+                        return false;
+                }
+                return true;
+            }
+       }
+    }
+
+    private String unescape(String s) {
+        StringBuilder sb = new StringBuilder(s.length());
+        for (int i = 0; i < s.length(); ++i) {
+            char c = s.charAt(i);
+            if (c == '\\') {
+                if (++i >= s.length()) {
+                    Log.e(TAG, "Unexpected end of escape sequence in: " + s);
+                    break;
+                }
+                c = s.charAt(i);
+                if (c != '.' && c != '\\') {
+                    if (i + 2 >= s.length()) {
+                        Log.e(TAG, "Unexpected end of escape sequence in: " + s);
+                        break;
+                    }
+                    c = (char) ((c-'0') * 100 + (s.charAt(i+1)-'0') * 10 + (s.charAt(i+2)-'0'));
+                    i += 2;
+                }
+            }
+            sb.append(c);
+        }
+        return sb.toString();
+    }
+
+    @VisibleForTesting
+    NsdService(Context ctx, Handler handler, DaemonConnectionSupplier fn, long cleanupDelayMs) {
+        mCleanupDelayMs = cleanupDelayMs;
+        mContext = ctx;
+        mNsdStateMachine = new NsdStateMachine(TAG, handler);
+        mNsdStateMachine.start();
+        mDaemonCallback = new NativeCallbackReceiver();
+        mDaemon = fn.get(mDaemonCallback);
+    }
+
+    public static NsdService create(Context context) throws InterruptedException {
+        HandlerThread thread = new HandlerThread(TAG);
+        thread.start();
+        Handler handler = new Handler(thread.getLooper());
+        NsdService service =
+                new NsdService(context, handler, DaemonConnection::new, CLEANUP_DELAY_MS);
+        service.mDaemonCallback.awaitConnection();
+        return service;
+    }
+
+    @Override
+    public INsdServiceConnector connect(INsdManagerCallback cb) {
+        mContext.enforceCallingOrSelfPermission(android.Manifest.permission.INTERNET, "NsdService");
+        final INsdServiceConnector connector = new NsdServiceConnector();
+        mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
+                NsdManager.REGISTER_CLIENT, new Pair<>(connector, cb)));
+        return connector;
+    }
+
+    private static class ListenerArgs {
+        public final NsdServiceConnector connector;
+        public final NsdServiceInfo serviceInfo;
+        ListenerArgs(NsdServiceConnector connector, NsdServiceInfo serviceInfo) {
+            this.connector = connector;
+            this.serviceInfo = serviceInfo;
+        }
+    }
+
+    private class NsdServiceConnector extends INsdServiceConnector.Stub
+            implements IBinder.DeathRecipient  {
+        @Override
+        public void registerService(int listenerKey, NsdServiceInfo serviceInfo) {
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
+                    NsdManager.REGISTER_SERVICE, 0, listenerKey,
+                    new ListenerArgs(this, serviceInfo)));
+        }
+
+        @Override
+        public void unregisterService(int listenerKey) {
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
+                    NsdManager.UNREGISTER_SERVICE, 0, listenerKey,
+                    new ListenerArgs(this, null)));
+        }
+
+        @Override
+        public void discoverServices(int listenerKey, NsdServiceInfo serviceInfo) {
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
+                    NsdManager.DISCOVER_SERVICES, 0, listenerKey,
+                    new ListenerArgs(this, serviceInfo)));
+        }
+
+        @Override
+        public void stopDiscovery(int listenerKey) {
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
+                    NsdManager.STOP_DISCOVERY, 0, listenerKey, new ListenerArgs(this, null)));
+        }
+
+        @Override
+        public void resolveService(int listenerKey, NsdServiceInfo serviceInfo) {
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
+                    NsdManager.RESOLVE_SERVICE, 0, listenerKey,
+                    new ListenerArgs(this, serviceInfo)));
+        }
+
+        @Override
+        public void startDaemon() {
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
+                    NsdManager.DAEMON_STARTUP, new ListenerArgs(this, null)));
+        }
+
+        @Override
+        public void binderDied() {
+            mNsdStateMachine.sendMessage(
+                    mNsdStateMachine.obtainMessage(NsdManager.UNREGISTER_CLIENT, this));
+        }
+    }
+
+    private void sendNsdStateChangeBroadcast(boolean isEnabled) {
+        final Intent intent = new Intent(NsdManager.ACTION_NSD_STATE_CHANGED);
+        intent.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY_BEFORE_BOOT);
+        int nsdState = isEnabled ? NsdManager.NSD_STATE_ENABLED : NsdManager.NSD_STATE_DISABLED;
+        intent.putExtra(NsdManager.EXTRA_NSD_STATE, nsdState);
+        mContext.sendStickyBroadcastAsUser(intent, UserHandle.ALL);
+    }
+
+    private int getUniqueId() {
+        if (++mUniqueId == INVALID_ID) return ++mUniqueId;
+        return mUniqueId;
+    }
+
+    /* These should be in sync with system/netd/server/ResponseCode.h */
+    static final class NativeResponseCode {
+        public static final int SERVICE_DISCOVERY_FAILED    =   602;
+        public static final int SERVICE_FOUND               =   603;
+        public static final int SERVICE_LOST                =   604;
+
+        public static final int SERVICE_REGISTRATION_FAILED =   605;
+        public static final int SERVICE_REGISTERED          =   606;
+
+        public static final int SERVICE_RESOLUTION_FAILED   =   607;
+        public static final int SERVICE_RESOLVED            =   608;
+
+        public static final int SERVICE_UPDATED             =   609;
+        public static final int SERVICE_UPDATE_FAILED       =   610;
+
+        public static final int SERVICE_GET_ADDR_FAILED     =   611;
+        public static final int SERVICE_GET_ADDR_SUCCESS    =   612;
+
+        private static final SparseArray<String> CODE_NAMES = new SparseArray<>();
+        static {
+            CODE_NAMES.put(SERVICE_DISCOVERY_FAILED, "SERVICE_DISCOVERY_FAILED");
+            CODE_NAMES.put(SERVICE_FOUND, "SERVICE_FOUND");
+            CODE_NAMES.put(SERVICE_LOST, "SERVICE_LOST");
+            CODE_NAMES.put(SERVICE_REGISTRATION_FAILED, "SERVICE_REGISTRATION_FAILED");
+            CODE_NAMES.put(SERVICE_REGISTERED, "SERVICE_REGISTERED");
+            CODE_NAMES.put(SERVICE_RESOLUTION_FAILED, "SERVICE_RESOLUTION_FAILED");
+            CODE_NAMES.put(SERVICE_RESOLVED, "SERVICE_RESOLVED");
+            CODE_NAMES.put(SERVICE_UPDATED, "SERVICE_UPDATED");
+            CODE_NAMES.put(SERVICE_UPDATE_FAILED, "SERVICE_UPDATE_FAILED");
+            CODE_NAMES.put(SERVICE_GET_ADDR_FAILED, "SERVICE_GET_ADDR_FAILED");
+            CODE_NAMES.put(SERVICE_GET_ADDR_SUCCESS, "SERVICE_GET_ADDR_SUCCESS");
+        }
+
+        static String nameOf(int code) {
+            String name = CODE_NAMES.get(code);
+            if (name == null) {
+                return Integer.toString(code);
+            }
+            return name;
+        }
+    }
+
+    private class NativeEvent {
+        final int code;
+        final String raw;
+        final String[] cooked;
+
+        NativeEvent(int code, String raw, String[] cooked) {
+            this.code = code;
+            this.raw = raw;
+            this.cooked = cooked;
+        }
+    }
+
+    class NativeCallbackReceiver implements INativeDaemonConnectorCallbacks {
+        private final CountDownLatch connected = new CountDownLatch(1);
+
+        public void awaitConnection() throws InterruptedException {
+            connected.await();
+        }
+
+        @Override
+        public void onDaemonConnected() {
+            connected.countDown();
+        }
+
+        @Override
+        public boolean onCheckHoldWakeLock(int code) {
+            return false;
+        }
+
+        @Override
+        public boolean onEvent(int code, String raw, String[] cooked) {
+            // TODO: NDC translates a message to a callback, we could enhance NDC to
+            // directly interact with a state machine through messages
+            NativeEvent event = new NativeEvent(code, raw, cooked);
+            mNsdStateMachine.sendMessage(NsdManager.NATIVE_DAEMON_EVENT, event);
+            return true;
+        }
+    }
+
+    interface DaemonConnectionSupplier {
+        DaemonConnection get(NativeCallbackReceiver callback);
+    }
+
+    @VisibleForTesting
+    public static class DaemonConnection {
+        final NativeDaemonConnector mNativeConnector;
+        boolean mIsStarted = false;
+
+        DaemonConnection(NativeCallbackReceiver callback) {
+            mNativeConnector = new NativeDaemonConnector(callback, "mdns", 10, MDNS_TAG, 25, null);
+            new Thread(mNativeConnector, MDNS_TAG).start();
+        }
+
+        /**
+         * Executes the specified cmd on the daemon.
+         */
+        public boolean execute(Object... args) {
+            if (DBG) {
+                Log.d(TAG, "mdnssd " + Arrays.toString(args));
+            }
+            try {
+                mNativeConnector.execute("mdnssd", args);
+            } catch (NativeDaemonConnectorException e) {
+                Log.e(TAG, "Failed to execute mdnssd " + Arrays.toString(args), e);
+                return false;
+            }
+            return true;
+        }
+
+        /**
+         * Starts the daemon if it is not already started.
+         */
+        public void maybeStart() {
+            if (mIsStarted) {
+                return;
+            }
+            execute("start-service");
+            mIsStarted = true;
+        }
+
+        /**
+         * Stops the daemon if it is started.
+         */
+        public void maybeStop() {
+            if (!mIsStarted) {
+                return;
+            }
+            execute("stop-service");
+            mIsStarted = false;
+        }
+    }
+
+    private boolean registerService(int regId, NsdServiceInfo service) {
+        if (DBG) {
+            Log.d(TAG, "registerService: " + regId + " " + service);
+        }
+        String name = service.getServiceName();
+        String type = service.getServiceType();
+        int port = service.getPort();
+        byte[] textRecord = service.getTxtRecord();
+        String record = Base64.encodeToString(textRecord, Base64.DEFAULT).replace("\n", "");
+        return mDaemon.execute("register", regId, name, type, port, record);
+    }
+
+    private boolean unregisterService(int regId) {
+        return mDaemon.execute("stop-register", regId);
+    }
+
+    private boolean updateService(int regId, DnsSdTxtRecord t) {
+        if (t == null) {
+            return false;
+        }
+        return mDaemon.execute("update", regId, t.size(), t.getRawData());
+    }
+
+    private boolean discoverServices(int discoveryId, NsdServiceInfo serviceInfo) {
+        final Network network = serviceInfo.getNetwork();
+        final int discoverInterface = getNetworkInterfaceIndex(network);
+        if (network != null && discoverInterface == IFACE_IDX_ANY) {
+            Log.e(TAG, "Interface to discover service on not found");
+            return false;
+        }
+        return mDaemon.execute("discover", discoveryId, serviceInfo.getServiceType(),
+                discoverInterface);
+    }
+
+    private boolean stopServiceDiscovery(int discoveryId) {
+        return mDaemon.execute("stop-discover", discoveryId);
+    }
+
+    private boolean resolveService(int resolveId, NsdServiceInfo service) {
+        final String name = service.getServiceName();
+        final String type = service.getServiceType();
+        final Network network = service.getNetwork();
+        final int resolveInterface = getNetworkInterfaceIndex(network);
+        if (network != null && resolveInterface == IFACE_IDX_ANY) {
+            Log.e(TAG, "Interface to resolve service on not found");
+            return false;
+        }
+        return mDaemon.execute("resolve", resolveId, name, type, "local.", resolveInterface);
+    }
+
+    /**
+     * Guess the interface to use to resolve or discover a service on a specific network.
+     *
+     * This is an imperfect guess, as for example the network may be gone or not yet fully
+     * registered. This is fine as failing is correct if the network is gone, and a client
+     * attempting to resolve/discover on a network not yet setup would have a bad time anyway; also
+     * this is to support the legacy mdnsresponder implementation, which historically resolved
+     * services on an unspecified network.
+     */
+    private int getNetworkInterfaceIndex(Network network) {
+        if (network == null) return IFACE_IDX_ANY;
+
+        final ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+        if (cm == null) {
+            Log.wtf(TAG, "No ConnectivityManager for resolveService");
+            return IFACE_IDX_ANY;
+        }
+        final LinkProperties lp = cm.getLinkProperties(network);
+        if (lp == null) return IFACE_IDX_ANY;
+
+        // Only resolve on non-stacked interfaces
+        final NetworkInterface iface;
+        try {
+            iface = NetworkInterface.getByName(lp.getInterfaceName());
+        } catch (SocketException e) {
+            Log.e(TAG, "Error querying interface", e);
+            return IFACE_IDX_ANY;
+        }
+
+        if (iface == null) {
+            Log.e(TAG, "Interface not found: " + lp.getInterfaceName());
+            return IFACE_IDX_ANY;
+        }
+
+        return iface.getIndex();
+    }
+
+    private boolean stopResolveService(int resolveId) {
+        return mDaemon.execute("stop-resolve", resolveId);
+    }
+
+    private boolean getAddrInfo(int resolveId, String hostname, String interfaceIdx) {
+        // interfaceIdx is always obtained (as string) from the service resolved callback
+        return mDaemon.execute("getaddrinfo", resolveId, hostname, interfaceIdx);
+    }
+
+    private boolean stopGetAddrInfo(int resolveId) {
+        return mDaemon.execute("stop-getaddrinfo", resolveId);
+    }
+
+    @Override
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        if (mContext.checkCallingOrSelfPermission(android.Manifest.permission.DUMP)
+                != PackageManager.PERMISSION_GRANTED) {
+            pw.println("Permission Denial: can't dump " + TAG
+                    + " due to missing android.permission.DUMP permission");
+            return;
+        }
+
+        for (ClientInfo client : mClients.values()) {
+            pw.println("Client Info");
+            pw.println(client);
+        }
+
+        mNsdStateMachine.dump(fd, pw, args);
+    }
+
+    /* Information tracked per client */
+    private class ClientInfo {
+
+        private static final int MAX_LIMIT = 10;
+        private final INsdManagerCallback mCb;
+        /* Remembers a resolved service until getaddrinfo completes */
+        private NsdServiceInfo mResolvedService;
+
+        /* A map from client id to unique id sent to mDns */
+        private final SparseIntArray mClientIds = new SparseIntArray();
+
+        /* A map from client id to the type of the request we had received */
+        private final SparseIntArray mClientRequests = new SparseIntArray();
+
+        // The target SDK of this client < Build.VERSION_CODES.S
+        private boolean mIsLegacy = false;
+
+        private ClientInfo(INsdManagerCallback cb) {
+            mCb = cb;
+            if (DBG) Log.d(TAG, "New client");
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder sb = new StringBuilder();
+            sb.append("mResolvedService ").append(mResolvedService).append("\n");
+            sb.append("mIsLegacy ").append(mIsLegacy).append("\n");
+            for(int i = 0; i< mClientIds.size(); i++) {
+                int clientID = mClientIds.keyAt(i);
+                sb.append("clientId ").append(clientID).
+                    append(" mDnsId ").append(mClientIds.valueAt(i)).
+                    append(" type ").append(mClientRequests.get(clientID)).append("\n");
+            }
+            return sb.toString();
+        }
+
+        private boolean isLegacy() {
+            return mIsLegacy;
+        }
+
+        private void setLegacy() {
+            mIsLegacy = true;
+        }
+
+        // Remove any pending requests from the global map when we get rid of a client,
+        // and send cancellations to the daemon.
+        private void expungeAllRequests() {
+            int globalId, clientId, i;
+            // TODO: to keep handler responsive, do not clean all requests for that client at once.
+            for (i = 0; i < mClientIds.size(); i++) {
+                clientId = mClientIds.keyAt(i);
+                globalId = mClientIds.valueAt(i);
+                mIdToClientInfoMap.remove(globalId);
+                if (DBG) {
+                    Log.d(TAG, "Terminating client-ID " + clientId
+                            + " global-ID " + globalId + " type " + mClientRequests.get(clientId));
+                }
+                switch (mClientRequests.get(clientId)) {
+                    case NsdManager.DISCOVER_SERVICES:
+                        stopServiceDiscovery(globalId);
+                        break;
+                    case NsdManager.RESOLVE_SERVICE:
+                        stopResolveService(globalId);
+                        break;
+                    case NsdManager.REGISTER_SERVICE:
+                        unregisterService(globalId);
+                        break;
+                    default:
+                        break;
+                }
+            }
+            mClientIds.clear();
+            mClientRequests.clear();
+        }
+
+        // mClientIds is a sparse array of listener id -> mDnsClient id.  For a given mDnsClient id,
+        // return the corresponding listener id.  mDnsClient id is also called a global id.
+        private int getClientId(final int globalId) {
+            int idx = mClientIds.indexOfValue(globalId);
+            if (idx < 0) {
+                return idx;
+            }
+            return mClientIds.keyAt(idx);
+        }
+
+        void onDiscoverServicesStarted(int listenerKey, NsdServiceInfo info) {
+            try {
+                mCb.onDiscoverServicesStarted(listenerKey, info);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onDiscoverServicesStarted", e);
+            }
+        }
+
+        void onDiscoverServicesFailed(int listenerKey, int error) {
+            try {
+                mCb.onDiscoverServicesFailed(listenerKey, error);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onDiscoverServicesFailed", e);
+            }
+        }
+
+        void onServiceFound(int listenerKey, NsdServiceInfo info) {
+            try {
+                mCb.onServiceFound(listenerKey, info);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onServiceFound(", e);
+            }
+        }
+
+        void onServiceLost(int listenerKey, NsdServiceInfo info) {
+            try {
+                mCb.onServiceLost(listenerKey, info);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onServiceLost(", e);
+            }
+        }
+
+        void onStopDiscoveryFailed(int listenerKey, int error) {
+            try {
+                mCb.onStopDiscoveryFailed(listenerKey, error);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onStopDiscoveryFailed", e);
+            }
+        }
+
+        void onStopDiscoverySucceeded(int listenerKey) {
+            try {
+                mCb.onStopDiscoverySucceeded(listenerKey);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onStopDiscoverySucceeded", e);
+            }
+        }
+
+        void onRegisterServiceFailed(int listenerKey, int error) {
+            try {
+                mCb.onRegisterServiceFailed(listenerKey, error);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onRegisterServiceFailed", e);
+            }
+        }
+
+        void onRegisterServiceSucceeded(int listenerKey, NsdServiceInfo info) {
+            try {
+                mCb.onRegisterServiceSucceeded(listenerKey, info);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onRegisterServiceSucceeded", e);
+            }
+        }
+
+        void onUnregisterServiceFailed(int listenerKey, int error) {
+            try {
+                mCb.onUnregisterServiceFailed(listenerKey, error);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onUnregisterServiceFailed", e);
+            }
+        }
+
+        void onUnregisterServiceSucceeded(int listenerKey) {
+            try {
+                mCb.onUnregisterServiceSucceeded(listenerKey);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onUnregisterServiceSucceeded", e);
+            }
+        }
+
+        void onResolveServiceFailed(int listenerKey, int error) {
+            try {
+                mCb.onResolveServiceFailed(listenerKey, error);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onResolveServiceFailed", e);
+            }
+        }
+
+        void onResolveServiceSucceeded(int listenerKey, NsdServiceInfo info) {
+            try {
+                mCb.onResolveServiceSucceeded(listenerKey, info);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error calling onResolveServiceSucceeded", e);
+            }
+        }
+    }
+}
diff --git a/service-t/src/com/android/server/net/BpfInterfaceMapUpdater.java b/service-t/src/com/android/server/net/BpfInterfaceMapUpdater.java
new file mode 100644
index 0000000..25c88eb
--- /dev/null
+++ b/service-t/src/com/android/server/net/BpfInterfaceMapUpdater.java
@@ -0,0 +1,139 @@
+/*
+ * 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.net;
+
+import android.content.Context;
+import android.net.INetd;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.system.ErrnoException;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
+import com.android.net.module.util.BpfMap;
+import com.android.net.module.util.IBpfMap;
+import com.android.net.module.util.InterfaceParams;
+import com.android.net.module.util.Struct.U32;
+
+/**
+ * Monitor interface added (without removed) and right interface name and its index to bpf map.
+ */
+public class BpfInterfaceMapUpdater {
+    private static final String TAG = BpfInterfaceMapUpdater.class.getSimpleName();
+    // This is current path but may be changed soon.
+    private static final String IFACE_INDEX_NAME_MAP_PATH =
+            "/sys/fs/bpf/map_netd_iface_index_name_map";
+    private final IBpfMap<U32, InterfaceMapValue> mBpfMap;
+    private final INetd mNetd;
+    private final Handler mHandler;
+    private final Dependencies mDeps;
+
+    public BpfInterfaceMapUpdater(Context ctx, Handler handler) {
+        this(ctx, handler, new Dependencies());
+    }
+
+    @VisibleForTesting
+    public BpfInterfaceMapUpdater(Context ctx, Handler handler, Dependencies deps) {
+        mDeps = deps;
+        mBpfMap = deps.getInterfaceMap();
+        mNetd = deps.getINetd(ctx);
+        mHandler = handler;
+    }
+
+    /**
+     * Dependencies of BpfInerfaceMapUpdater, for injection in tests.
+     */
+    @VisibleForTesting
+    public static class Dependencies {
+        /** Create BpfMap for updating interface and index mapping. */
+        public IBpfMap<U32, InterfaceMapValue> getInterfaceMap() {
+            try {
+                return new BpfMap<>(IFACE_INDEX_NAME_MAP_PATH, BpfMap.BPF_F_RDWR,
+                    U32.class, InterfaceMapValue.class);
+            } catch (ErrnoException e) {
+                Log.e(TAG, "Cannot create interface map: " + e);
+                return null;
+            }
+        }
+
+        /** Get InterfaceParams for giving interface name. */
+        public InterfaceParams getInterfaceParams(String ifaceName) {
+            return InterfaceParams.getByName(ifaceName);
+        }
+
+        /** Get INetd binder object. */
+        public INetd getINetd(Context ctx) {
+            return INetd.Stub.asInterface((IBinder) ctx.getSystemService(Context.NETD_SERVICE));
+        }
+    }
+
+    /**
+     * Start listening interface update event.
+     * Query current interface names before listening.
+     */
+    public void start() {
+        mHandler.post(() -> {
+            if (mBpfMap == null) {
+                Log.wtf(TAG, "Fail to start: Null bpf map");
+                return;
+            }
+
+            try {
+                // TODO: use a NetlinkMonitor and listen for RTM_NEWLINK messages instead.
+                mNetd.registerUnsolicitedEventListener(new InterfaceChangeObserver());
+            } catch (RemoteException e) {
+                Log.wtf(TAG, "Unable to register netd UnsolicitedEventListener, " + e);
+            }
+
+            final String[] ifaces;
+            try {
+                // TODO: use a netlink dump to get the current interface list.
+                ifaces = mNetd.interfaceGetList();
+            } catch (RemoteException | ServiceSpecificException e) {
+                Log.wtf(TAG, "Unable to query interface names by netd, " + e);
+                return;
+            }
+
+            for (String ifaceName : ifaces) {
+                addInterface(ifaceName);
+            }
+        });
+    }
+
+    private void addInterface(String ifaceName) {
+        final InterfaceParams iface = mDeps.getInterfaceParams(ifaceName);
+        if (iface == null) {
+            Log.e(TAG, "Unable to get InterfaceParams for " + ifaceName);
+            return;
+        }
+
+        try {
+            mBpfMap.updateEntry(new U32(iface.index), new InterfaceMapValue(ifaceName));
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Unable to update entry for " + ifaceName + ", " + e);
+        }
+    }
+
+    private class InterfaceChangeObserver extends BaseNetdUnsolicitedEventListener {
+        @Override
+        public void onInterfaceAdded(String ifName) {
+            mHandler.post(() -> addInterface(ifName));
+        }
+    }
+}
diff --git a/service-t/src/com/android/server/net/CookieTagMapKey.java b/service-t/src/com/android/server/net/CookieTagMapKey.java
new file mode 100644
index 0000000..443e5b3
--- /dev/null
+++ b/service-t/src/com/android/server/net/CookieTagMapKey.java
@@ -0,0 +1,33 @@
+/*
+ * 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.net;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Field;
+import com.android.net.module.util.Struct.Type;
+
+/**
+ * Key for cookie tag map.
+ */
+public class CookieTagMapKey extends Struct {
+    @Field(order = 0, type = Type.S64)
+    public final long socketCookie;
+
+    public CookieTagMapKey(final long socketCookie) {
+        this.socketCookie = socketCookie;
+    }
+}
diff --git a/service-t/src/com/android/server/net/CookieTagMapValue.java b/service-t/src/com/android/server/net/CookieTagMapValue.java
new file mode 100644
index 0000000..93b9195
--- /dev/null
+++ b/service-t/src/com/android/server/net/CookieTagMapValue.java
@@ -0,0 +1,37 @@
+/*
+ * 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.net;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Field;
+import com.android.net.module.util.Struct.Type;
+
+/**
+ * Value for cookie tag map.
+ */
+public class CookieTagMapValue extends Struct {
+    @Field(order = 0, type = Type.U32)
+    public final long uid;
+
+    @Field(order = 1, type = Type.U32)
+    public final long tag;
+
+    public CookieTagMapValue(final long uid, final long tag) {
+        this.uid = uid;
+        this.tag = tag;
+    }
+}
diff --git a/service-t/src/com/android/server/net/DelayedDiskWrite.java b/service-t/src/com/android/server/net/DelayedDiskWrite.java
new file mode 100644
index 0000000..35dc455
--- /dev/null
+++ b/service-t/src/com/android/server/net/DelayedDiskWrite.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2014 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.os.Handler;
+import android.os.HandlerThread;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.BufferedOutputStream;
+import java.io.DataOutputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+/**
+ * This class provides APIs to do a delayed data write to a given {@link OutputStream}.
+ */
+public class DelayedDiskWrite {
+    private static final String TAG = "DelayedDiskWrite";
+
+    private HandlerThread mDiskWriteHandlerThread;
+    private Handler mDiskWriteHandler;
+    /* Tracks multiple writes on the same thread */
+    private int mWriteSequence = 0;
+
+    /**
+     * Used to do a delayed data write to a given {@link OutputStream}.
+     */
+    public interface Writer {
+        /**
+         * write data to a given {@link OutputStream}.
+         */
+        void onWriteCalled(DataOutputStream out) throws IOException;
+    }
+
+    /**
+     * Do a delayed data write to a given output stream opened from filePath.
+     */
+    public void write(final String filePath, final Writer w) {
+        write(filePath, w, true);
+    }
+
+    /**
+     * Do a delayed data write to a given output stream opened from filePath.
+     */
+    public void write(final String filePath, final Writer w, final boolean open) {
+        if (TextUtils.isEmpty(filePath)) {
+            throw new IllegalArgumentException("empty file path");
+        }
+
+        /* Do a delayed write to disk on a separate handler thread */
+        synchronized (this) {
+            if (++mWriteSequence == 1) {
+                mDiskWriteHandlerThread = new HandlerThread("DelayedDiskWriteThread");
+                mDiskWriteHandlerThread.start();
+                mDiskWriteHandler = new Handler(mDiskWriteHandlerThread.getLooper());
+            }
+        }
+
+        mDiskWriteHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                doWrite(filePath, w, open);
+            }
+        });
+    }
+
+    private void doWrite(String filePath, Writer w, boolean open) {
+        DataOutputStream out = null;
+        try {
+            if (open) {
+                out = new DataOutputStream(new BufferedOutputStream(
+                        new FileOutputStream(filePath)));
+            }
+            w.onWriteCalled(out);
+        } catch (IOException e) {
+            loge("Error writing data file " + filePath);
+        } finally {
+            if (out != null) {
+                try {
+                    out.close();
+                } catch (Exception e) { }
+            }
+
+            // Quit if no more writes sent
+            synchronized (this) {
+                if (--mWriteSequence == 0) {
+                    mDiskWriteHandler.getLooper().quit();
+                    mDiskWriteHandler = null;
+                    mDiskWriteHandlerThread = null;
+                }
+            }
+        }
+    }
+
+    private void loge(String s) {
+        Log.e(TAG, s);
+    }
+}
+
diff --git a/service-t/src/com/android/server/net/InterfaceMapValue.java b/service-t/src/com/android/server/net/InterfaceMapValue.java
new file mode 100644
index 0000000..42c0044
--- /dev/null
+++ b/service-t/src/com/android/server/net/InterfaceMapValue.java
@@ -0,0 +1,35 @@
+/*
+ * 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.net;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Field;
+import com.android.net.module.util.Struct.Type;
+
+/**
+ * The value of bpf interface index map which is used for NetworkStatsService.
+ */
+public class InterfaceMapValue extends Struct {
+    @Field(order = 0, type = Type.ByteArray, arraysize = 16)
+    public final byte[] interfaceName;
+
+    public InterfaceMapValue(String iface) {
+        final byte[] ifaceArray = iface.getBytes();
+        interfaceName = new byte[16];
+        // All array bytes after the interface name, if any, must be 0.
+        System.arraycopy(ifaceArray, 0, interfaceName, 0, ifaceArray.length);
+    }
+}
diff --git a/service-t/src/com/android/server/net/IpConfigStore.java b/service-t/src/com/android/server/net/IpConfigStore.java
new file mode 100644
index 0000000..3a9a544
--- /dev/null
+++ b/service-t/src/com/android/server/net/IpConfigStore.java
@@ -0,0 +1,449 @@
+/*
+ * Copyright (C) 2014 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.net.InetAddresses;
+import android.net.IpConfiguration;
+import android.net.IpConfiguration.IpAssignment;
+import android.net.IpConfiguration.ProxySettings;
+import android.net.LinkAddress;
+import android.net.ProxyInfo;
+import android.net.StaticIpConfiguration;
+import android.net.Uri;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.ProxyUtils;
+
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This class provides an API to store and manage L3 network IP configuration.
+ */
+public class IpConfigStore {
+    private static final String TAG = "IpConfigStore";
+    private static final boolean DBG = false;
+
+    protected final DelayedDiskWrite mWriter;
+
+    /* IP and proxy configuration keys */
+    protected static final String ID_KEY = "id";
+    protected static final String IP_ASSIGNMENT_KEY = "ipAssignment";
+    protected static final String LINK_ADDRESS_KEY = "linkAddress";
+    protected static final String GATEWAY_KEY = "gateway";
+    protected static final String DNS_KEY = "dns";
+    protected static final String PROXY_SETTINGS_KEY = "proxySettings";
+    protected static final String PROXY_HOST_KEY = "proxyHost";
+    protected static final String PROXY_PORT_KEY = "proxyPort";
+    protected static final String PROXY_PAC_FILE = "proxyPac";
+    protected static final String EXCLUSION_LIST_KEY = "exclusionList";
+    protected static final String EOS = "eos";
+
+    protected static final int IPCONFIG_FILE_VERSION = 3;
+
+    public IpConfigStore(DelayedDiskWrite writer) {
+        mWriter = writer;
+    }
+
+    public IpConfigStore() {
+        this(new DelayedDiskWrite());
+    }
+
+    private static boolean writeConfig(DataOutputStream out, String configKey,
+            IpConfiguration config) throws IOException {
+        return writeConfig(out, configKey, config, IPCONFIG_FILE_VERSION);
+    }
+
+    /**
+     *  Write the IP configuration with the given parameters to {@link DataOutputStream}.
+     */
+    @VisibleForTesting
+    public static boolean writeConfig(DataOutputStream out, String configKey,
+                                IpConfiguration config, int version) throws IOException {
+        boolean written = false;
+
+        try {
+            switch (config.getIpAssignment()) {
+                case STATIC:
+                    out.writeUTF(IP_ASSIGNMENT_KEY);
+                    out.writeUTF(config.getIpAssignment().toString());
+                    StaticIpConfiguration staticIpConfiguration = config.getStaticIpConfiguration();
+                    if (staticIpConfiguration != null) {
+                        if (staticIpConfiguration.getIpAddress() != null) {
+                            LinkAddress ipAddress = staticIpConfiguration.getIpAddress();
+                            out.writeUTF(LINK_ADDRESS_KEY);
+                            out.writeUTF(ipAddress.getAddress().getHostAddress());
+                            out.writeInt(ipAddress.getPrefixLength());
+                        }
+                        if (staticIpConfiguration.getGateway() != null) {
+                            out.writeUTF(GATEWAY_KEY);
+                            out.writeInt(0);  // Default route.
+                            out.writeInt(1);  // Have a gateway.
+                            out.writeUTF(staticIpConfiguration.getGateway().getHostAddress());
+                        }
+                        for (InetAddress inetAddr : staticIpConfiguration.getDnsServers()) {
+                            out.writeUTF(DNS_KEY);
+                            out.writeUTF(inetAddr.getHostAddress());
+                        }
+                    }
+                    written = true;
+                    break;
+                case DHCP:
+                    out.writeUTF(IP_ASSIGNMENT_KEY);
+                    out.writeUTF(config.getIpAssignment().toString());
+                    written = true;
+                    break;
+                case UNASSIGNED:
+                /* Ignore */
+                    break;
+                default:
+                    loge("Ignore invalid ip assignment while writing");
+                    break;
+            }
+
+            switch (config.getProxySettings()) {
+                case STATIC:
+                    ProxyInfo proxyProperties = config.getHttpProxy();
+                    String exclusionList = ProxyUtils.exclusionListAsString(
+                            proxyProperties.getExclusionList());
+                    out.writeUTF(PROXY_SETTINGS_KEY);
+                    out.writeUTF(config.getProxySettings().toString());
+                    out.writeUTF(PROXY_HOST_KEY);
+                    out.writeUTF(proxyProperties.getHost());
+                    out.writeUTF(PROXY_PORT_KEY);
+                    out.writeInt(proxyProperties.getPort());
+                    if (exclusionList != null) {
+                        out.writeUTF(EXCLUSION_LIST_KEY);
+                        out.writeUTF(exclusionList);
+                    }
+                    written = true;
+                    break;
+                case PAC:
+                    ProxyInfo proxyPacProperties = config.getHttpProxy();
+                    out.writeUTF(PROXY_SETTINGS_KEY);
+                    out.writeUTF(config.getProxySettings().toString());
+                    out.writeUTF(PROXY_PAC_FILE);
+                    out.writeUTF(proxyPacProperties.getPacFileUrl().toString());
+                    written = true;
+                    break;
+                case NONE:
+                    out.writeUTF(PROXY_SETTINGS_KEY);
+                    out.writeUTF(config.getProxySettings().toString());
+                    written = true;
+                    break;
+                case UNASSIGNED:
+                    /* Ignore */
+                    break;
+                default:
+                    loge("Ignore invalid proxy settings while writing");
+                    break;
+            }
+
+            if (written) {
+                out.writeUTF(ID_KEY);
+                if (version < 3) {
+                    out.writeInt(Integer.valueOf(configKey));
+                } else {
+                    out.writeUTF(configKey);
+                }
+            }
+        } catch (NullPointerException e) {
+            loge("Failure in writing " + config + e);
+        }
+        out.writeUTF(EOS);
+
+        return written;
+    }
+
+    /**
+     * @deprecated use {@link #writeIpConfigurations(String, ArrayMap)} instead.
+     * New method uses string as network identifier which could be interface name or MAC address or
+     * other token.
+     */
+    @Deprecated
+    public void writeIpAndProxyConfigurationsToFile(String filePath,
+                                              final SparseArray<IpConfiguration> networks) {
+        mWriter.write(filePath, out -> {
+            out.writeInt(IPCONFIG_FILE_VERSION);
+            for (int i = 0; i < networks.size(); i++) {
+                writeConfig(out, String.valueOf(networks.keyAt(i)), networks.valueAt(i));
+            }
+        });
+    }
+
+    /**
+     *  Write the IP configuration associated to the target networks to the destination path.
+     */
+    public void writeIpConfigurations(String filePath,
+                                      ArrayMap<String, IpConfiguration> networks) {
+        mWriter.write(filePath, out -> {
+            out.writeInt(IPCONFIG_FILE_VERSION);
+            for (int i = 0; i < networks.size(); i++) {
+                writeConfig(out, networks.keyAt(i), networks.valueAt(i));
+            }
+        });
+    }
+
+    /**
+     * Read the IP configuration from the destination path to {@link BufferedInputStream}.
+     */
+    public static ArrayMap<String, IpConfiguration> readIpConfigurations(String filePath) {
+        BufferedInputStream bufferedInputStream;
+        try {
+            bufferedInputStream = new BufferedInputStream(new FileInputStream(filePath));
+        } catch (FileNotFoundException e) {
+            // Return an empty array here because callers expect an empty array when the file is
+            // not present.
+            loge("Error opening configuration file: " + e);
+            return new ArrayMap<>(0);
+        }
+        return readIpConfigurations(bufferedInputStream);
+    }
+
+    /** @deprecated use {@link #readIpConfigurations(String)} */
+    @Deprecated
+    public static SparseArray<IpConfiguration> readIpAndProxyConfigurations(String filePath) {
+        BufferedInputStream bufferedInputStream;
+        try {
+            bufferedInputStream = new BufferedInputStream(new FileInputStream(filePath));
+        } catch (FileNotFoundException e) {
+            // Return an empty array here because callers expect an empty array when the file is
+            // not present.
+            loge("Error opening configuration file: " + e);
+            return new SparseArray<>();
+        }
+        return readIpAndProxyConfigurations(bufferedInputStream);
+    }
+
+    /** @deprecated use {@link #readIpConfigurations(InputStream)} */
+    @Deprecated
+    public static SparseArray<IpConfiguration> readIpAndProxyConfigurations(
+            InputStream inputStream) {
+        ArrayMap<String, IpConfiguration> networks = readIpConfigurations(inputStream);
+        if (networks == null) {
+            return null;
+        }
+
+        SparseArray<IpConfiguration> networksById = new SparseArray<>();
+        for (int i = 0; i < networks.size(); i++) {
+            int id = Integer.valueOf(networks.keyAt(i));
+            networksById.put(id, networks.valueAt(i));
+        }
+
+        return networksById;
+    }
+
+    /** Returns a map of network identity token and {@link IpConfiguration}. */
+    public static ArrayMap<String, IpConfiguration> readIpConfigurations(
+            InputStream inputStream) {
+        ArrayMap<String, IpConfiguration> networks = new ArrayMap<>();
+        DataInputStream in = null;
+        try {
+            in = new DataInputStream(inputStream);
+
+            int version = in.readInt();
+            if (version != 3 && version != 2 && version != 1) {
+                loge("Bad version on IP configuration file, ignore read");
+                return null;
+            }
+
+            while (true) {
+                String uniqueToken = null;
+                // Default is DHCP with no proxy
+                IpAssignment ipAssignment = IpAssignment.DHCP;
+                ProxySettings proxySettings = ProxySettings.NONE;
+                StaticIpConfiguration staticIpConfiguration = new StaticIpConfiguration();
+                LinkAddress linkAddress = null;
+                InetAddress gatewayAddress = null;
+                String proxyHost = null;
+                String pacFileUrl = null;
+                int proxyPort = -1;
+                String exclusionList = null;
+                String key;
+                final List<InetAddress> dnsServers = new ArrayList<>();
+
+                do {
+                    key = in.readUTF();
+                    try {
+                        if (key.equals(ID_KEY)) {
+                            if (version < 3) {
+                                int id = in.readInt();
+                                uniqueToken = String.valueOf(id);
+                            } else {
+                                uniqueToken = in.readUTF();
+                            }
+                        } else if (key.equals(IP_ASSIGNMENT_KEY)) {
+                            ipAssignment = IpAssignment.valueOf(in.readUTF());
+                        } else if (key.equals(LINK_ADDRESS_KEY)) {
+                            LinkAddress parsedLinkAddress =
+                                    new LinkAddress(
+                                            InetAddresses.parseNumericAddress(in.readUTF()),
+                                            in.readInt());
+                            if (parsedLinkAddress.getAddress() instanceof Inet4Address
+                                    && linkAddress == null) {
+                                linkAddress = parsedLinkAddress;
+                            } else {
+                                loge("Non-IPv4 or duplicate address: " + parsedLinkAddress);
+                            }
+                        } else if (key.equals(GATEWAY_KEY)) {
+                            LinkAddress dest = null;
+                            InetAddress gateway = null;
+                            if (version == 1) {
+                                // only supported default gateways - leave the dest/prefix empty
+                                gateway = InetAddresses.parseNumericAddress(in.readUTF());
+                                if (gatewayAddress == null) {
+                                    gatewayAddress = gateway;
+                                } else {
+                                    loge("Duplicate gateway: " + gateway.getHostAddress());
+                                }
+                            } else {
+                                if (in.readInt() == 1) {
+                                    dest =
+                                            new LinkAddress(
+                                                    InetAddresses.parseNumericAddress(in.readUTF()),
+                                                    in.readInt());
+                                }
+                                if (in.readInt() == 1) {
+                                    gateway = InetAddresses.parseNumericAddress(in.readUTF());
+                                }
+                                // If the destination is a default IPv4 route, use the gateway
+                                // address unless already set. If there is no destination, assume
+                                // it is default route and use the gateway address in all cases.
+                                if (dest == null) {
+                                    gatewayAddress = gateway;
+                                } else if (dest.getAddress() instanceof Inet4Address
+                                        && dest.getPrefixLength() == 0 && gatewayAddress == null) {
+                                    gatewayAddress = gateway;
+                                } else {
+                                    loge("Non-IPv4 default or duplicate route: "
+                                            + dest.getAddress());
+                                }
+                            }
+                        } else if (key.equals(DNS_KEY)) {
+                            dnsServers.add(InetAddresses.parseNumericAddress(in.readUTF()));
+                        } else if (key.equals(PROXY_SETTINGS_KEY)) {
+                            proxySettings = ProxySettings.valueOf(in.readUTF());
+                        } else if (key.equals(PROXY_HOST_KEY)) {
+                            proxyHost = in.readUTF();
+                        } else if (key.equals(PROXY_PORT_KEY)) {
+                            proxyPort = in.readInt();
+                        } else if (key.equals(PROXY_PAC_FILE)) {
+                            pacFileUrl = in.readUTF();
+                        } else if (key.equals(EXCLUSION_LIST_KEY)) {
+                            exclusionList = in.readUTF();
+                        } else if (key.equals(EOS)) {
+                            break;
+                        } else {
+                            loge("Ignore unknown key " + key + "while reading");
+                        }
+                    } catch (IllegalArgumentException e) {
+                        loge("Ignore invalid address while reading" + e);
+                    }
+                } while (true);
+
+                staticIpConfiguration = new StaticIpConfiguration.Builder()
+                    .setIpAddress(linkAddress)
+                    .setGateway(gatewayAddress)
+                    .setDnsServers(dnsServers)
+                    .build();
+
+                if (uniqueToken != null) {
+                    IpConfiguration config = new IpConfiguration();
+                    networks.put(uniqueToken, config);
+
+                    switch (ipAssignment) {
+                        case STATIC:
+                            config.setStaticIpConfiguration(staticIpConfiguration);
+                            config.setIpAssignment(ipAssignment);
+                            break;
+                        case DHCP:
+                            config.setIpAssignment(ipAssignment);
+                            break;
+                        case UNASSIGNED:
+                            loge("BUG: Found UNASSIGNED IP on file, use DHCP");
+                            config.setIpAssignment(IpAssignment.DHCP);
+                            break;
+                        default:
+                            loge("Ignore invalid ip assignment while reading.");
+                            config.setIpAssignment(IpAssignment.UNASSIGNED);
+                            break;
+                    }
+
+                    switch (proxySettings) {
+                        case STATIC:
+                            ProxyInfo proxyInfo = ProxyInfo.buildDirectProxy(proxyHost, proxyPort,
+                                    ProxyUtils.exclusionStringAsList(exclusionList));
+                            config.setProxySettings(proxySettings);
+                            config.setHttpProxy(proxyInfo);
+                            break;
+                        case PAC:
+                            ProxyInfo proxyPacProperties =
+                                    ProxyInfo.buildPacProxy(Uri.parse(pacFileUrl));
+                            config.setProxySettings(proxySettings);
+                            config.setHttpProxy(proxyPacProperties);
+                            break;
+                        case NONE:
+                            config.setProxySettings(proxySettings);
+                            break;
+                        case UNASSIGNED:
+                            loge("BUG: Found UNASSIGNED proxy on file, use NONE");
+                            config.setProxySettings(ProxySettings.NONE);
+                            break;
+                        default:
+                            loge("Ignore invalid proxy settings while reading");
+                            config.setProxySettings(ProxySettings.UNASSIGNED);
+                            break;
+                    }
+                } else {
+                    if (DBG) log("Missing id while parsing configuration");
+                }
+            }
+        } catch (EOFException ignore) {
+        } catch (IOException e) {
+            loge("Error parsing configuration: " + e);
+        } finally {
+            if (in != null) {
+                try {
+                    in.close();
+                } catch (Exception e) { }
+            }
+        }
+
+        return networks;
+    }
+
+    protected static void loge(String s) {
+        Log.e(TAG, s);
+    }
+
+    protected static void log(String s) {
+        Log.d(TAG, s);
+    }
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsFactory.java b/service-t/src/com/android/server/net/NetworkStatsFactory.java
new file mode 100644
index 0000000..3b93f1a
--- /dev/null
+++ b/service-t/src/com/android/server/net/NetworkStatsFactory.java
@@ -0,0 +1,505 @@
+/*
+ * Copyright (C) 2011 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 android.net.NetworkStats.INTERFACES_ALL;
+import static android.net.NetworkStats.SET_ALL;
+import static android.net.NetworkStats.TAG_ALL;
+import static android.net.NetworkStats.TAG_NONE;
+import static android.net.NetworkStats.UID_ALL;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.net.NetworkStats;
+import android.net.UnderlyingNetworkInfo;
+import android.os.ServiceSpecificException;
+import android.os.StrictMode;
+import android.os.SystemClock;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.ProcFileReader;
+import com.android.net.module.util.CollectionUtils;
+import com.android.server.BpfNetMaps;
+
+import libcore.io.IoUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.ProtocolException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Creates {@link NetworkStats} instances by parsing various {@code /proc/}
+ * files as needed.
+ *
+ * @hide
+ */
+public class NetworkStatsFactory {
+    static {
+        System.loadLibrary("service-connectivity");
+    }
+
+    private static final String TAG = "NetworkStatsFactory";
+
+    private static final boolean USE_NATIVE_PARSING = true;
+    private static final boolean VALIDATE_NATIVE_STATS = false;
+
+    /** Path to {@code /proc/net/xt_qtaguid/iface_stat_all}. */
+    private final File mStatsXtIfaceAll;
+    /** Path to {@code /proc/net/xt_qtaguid/iface_stat_fmt}. */
+    private final File mStatsXtIfaceFmt;
+    /** Path to {@code /proc/net/xt_qtaguid/stats}. */
+    private final File mStatsXtUid;
+
+    private final boolean mUseBpfStats;
+
+    private final Context mContext;
+
+    private final BpfNetMaps mBpfNetMaps;
+
+    /**
+     * Guards persistent data access in this class
+     *
+     * <p>In order to prevent deadlocks, critical sections protected by this lock SHALL NOT call out
+     * to other code that will acquire other locks within the system server. See b/134244752.
+     */
+    private final Object mPersistentDataLock = new Object();
+
+    /** Set containing info about active VPNs and their underlying networks. */
+    private volatile UnderlyingNetworkInfo[] mUnderlyingNetworkInfos = new UnderlyingNetworkInfo[0];
+
+    // A persistent snapshot of cumulative stats since device start
+    @GuardedBy("mPersistentDataLock")
+    private NetworkStats mPersistSnapshot;
+
+    // The persistent snapshot of tun and 464xlat adjusted stats since device start
+    @GuardedBy("mPersistentDataLock")
+    private NetworkStats mTunAnd464xlatAdjustedStats;
+
+    /**
+     * (Stacked interface) -> (base interface) association for all connected ifaces since boot.
+     *
+     * Because counters must never roll backwards, once a given interface is stacked on top of an
+     * underlying interface, the stacked interface can never be stacked on top of
+     * another interface. */
+    private final ConcurrentHashMap<String, String> mStackedIfaces
+            = new ConcurrentHashMap<>();
+
+    /** Informs the factory of a new stacked interface. */
+    public void noteStackedIface(String stackedIface, String baseIface) {
+        if (stackedIface != null && baseIface != null) {
+            mStackedIfaces.put(stackedIface, baseIface);
+        }
+    }
+
+    /**
+     * Set active VPN information for data usage migration purposes
+     *
+     * <p>Traffic on TUN-based VPNs inherently all appear to be originated from the VPN providing
+     * app's UID. This method is used to support migration of VPN data usage, ensuring data is
+     * accurately billed to the real owner of the traffic.
+     *
+     * @param vpnArray The snapshot of the currently-running VPNs.
+     */
+    public void updateUnderlyingNetworkInfos(UnderlyingNetworkInfo[] vpnArray) {
+        mUnderlyingNetworkInfos = vpnArray.clone();
+    }
+
+    /**
+     * Get a set of interfaces containing specified ifaces and stacked interfaces.
+     *
+     * <p>The added stacked interfaces are ifaces stacked on top of the specified ones, or ifaces
+     * on which the specified ones are stacked. Stacked interfaces are those noted with
+     * {@link #noteStackedIface(String, String)}, but only interfaces noted before this method
+     * is called are guaranteed to be included.
+     */
+    public String[] augmentWithStackedInterfaces(@Nullable String[] requiredIfaces) {
+        if (requiredIfaces == NetworkStats.INTERFACES_ALL) {
+            return null;
+        }
+
+        HashSet<String> relatedIfaces = new HashSet<>(Arrays.asList(requiredIfaces));
+        // ConcurrentHashMap's EntrySet iterators are "guaranteed to traverse
+        // elements as they existed upon construction exactly once, and may
+        // (but are not guaranteed to) reflect any modifications subsequent to construction".
+        // This is enough here.
+        for (Map.Entry<String, String> entry : mStackedIfaces.entrySet()) {
+            if (relatedIfaces.contains(entry.getKey())) {
+                relatedIfaces.add(entry.getValue());
+            } else if (relatedIfaces.contains(entry.getValue())) {
+                relatedIfaces.add(entry.getKey());
+            }
+        }
+
+        String[] outArray = new String[relatedIfaces.size()];
+        return relatedIfaces.toArray(outArray);
+    }
+
+    /**
+     * Applies 464xlat adjustments with ifaces noted with {@link #noteStackedIface(String, String)}.
+     * @see NetworkStats#apply464xlatAdjustments(NetworkStats, NetworkStats, Map)
+     */
+    public void apply464xlatAdjustments(NetworkStats baseTraffic, NetworkStats stackedTraffic) {
+        NetworkStats.apply464xlatAdjustments(baseTraffic, stackedTraffic, mStackedIfaces);
+    }
+
+    public NetworkStatsFactory(@NonNull Context ctx) {
+        this(ctx, new File("/proc/"), true);
+    }
+
+    @VisibleForTesting
+    public NetworkStatsFactory(@NonNull Context ctx, File procRoot, boolean useBpfStats) {
+        mStatsXtIfaceAll = new File(procRoot, "net/xt_qtaguid/iface_stat_all");
+        mStatsXtIfaceFmt = new File(procRoot, "net/xt_qtaguid/iface_stat_fmt");
+        mStatsXtUid = new File(procRoot, "net/xt_qtaguid/stats");
+        mUseBpfStats = useBpfStats;
+        mBpfNetMaps = new BpfNetMaps();
+        synchronized (mPersistentDataLock) {
+            mPersistSnapshot = new NetworkStats(SystemClock.elapsedRealtime(), -1);
+            mTunAnd464xlatAdjustedStats = new NetworkStats(SystemClock.elapsedRealtime(), -1);
+        }
+        mContext = ctx;
+    }
+
+    public NetworkStats readBpfNetworkStatsDev() throws IOException {
+        final NetworkStats stats = new NetworkStats(SystemClock.elapsedRealtime(), 6);
+        if (nativeReadNetworkStatsDev(stats) != 0) {
+            throw new IOException("Failed to parse bpf iface stats");
+        }
+        return stats;
+    }
+
+    /**
+     * Parse and return interface-level summary {@link NetworkStats} measured
+     * using {@code /proc/net/dev} style hooks, which may include non IP layer
+     * traffic. Values monotonically increase since device boot, and may include
+     * details about inactive interfaces.
+     *
+     * @throws IllegalStateException when problem parsing stats.
+     */
+    public NetworkStats readNetworkStatsSummaryDev() throws IOException {
+
+        // Return xt_bpf stats if switched to bpf module.
+        if (mUseBpfStats)
+            return readBpfNetworkStatsDev();
+
+        final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+
+        final NetworkStats stats = new NetworkStats(SystemClock.elapsedRealtime(), 6);
+        final NetworkStats.Entry entry = new NetworkStats.Entry();
+
+        ProcFileReader reader = null;
+        try {
+            reader = new ProcFileReader(new FileInputStream(mStatsXtIfaceAll));
+
+            while (reader.hasMoreData()) {
+                entry.iface = reader.nextString();
+                entry.uid = UID_ALL;
+                entry.set = SET_ALL;
+                entry.tag = TAG_NONE;
+
+                final boolean active = reader.nextInt() != 0;
+
+                // always include snapshot values
+                entry.rxBytes = reader.nextLong();
+                entry.rxPackets = reader.nextLong();
+                entry.txBytes = reader.nextLong();
+                entry.txPackets = reader.nextLong();
+
+                // fold in active numbers, but only when active
+                if (active) {
+                    entry.rxBytes += reader.nextLong();
+                    entry.rxPackets += reader.nextLong();
+                    entry.txBytes += reader.nextLong();
+                    entry.txPackets += reader.nextLong();
+                }
+
+                stats.insertEntry(entry);
+                reader.finishLine();
+            }
+        } catch (NullPointerException|NumberFormatException e) {
+            throw protocolExceptionWithCause("problem parsing stats", e);
+        } finally {
+            IoUtils.closeQuietly(reader);
+            StrictMode.setThreadPolicy(savedPolicy);
+        }
+        return stats;
+    }
+
+    /**
+     * Parse and return interface-level summary {@link NetworkStats}. Designed
+     * to return only IP layer traffic. Values monotonically increase since
+     * device boot, and may include details about inactive interfaces.
+     *
+     * @throws IllegalStateException when problem parsing stats.
+     */
+    public NetworkStats readNetworkStatsSummaryXt() throws IOException {
+
+        // Return xt_bpf stats if qtaguid  module is replaced.
+        if (mUseBpfStats)
+            return readBpfNetworkStatsDev();
+
+        final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+
+        // return null when kernel doesn't support
+        if (!mStatsXtIfaceFmt.exists()) return null;
+
+        final NetworkStats stats = new NetworkStats(SystemClock.elapsedRealtime(), 6);
+        final NetworkStats.Entry entry = new NetworkStats.Entry();
+
+        ProcFileReader reader = null;
+        try {
+            // open and consume header line
+            reader = new ProcFileReader(new FileInputStream(mStatsXtIfaceFmt));
+            reader.finishLine();
+
+            while (reader.hasMoreData()) {
+                entry.iface = reader.nextString();
+                entry.uid = UID_ALL;
+                entry.set = SET_ALL;
+                entry.tag = TAG_NONE;
+
+                entry.rxBytes = reader.nextLong();
+                entry.rxPackets = reader.nextLong();
+                entry.txBytes = reader.nextLong();
+                entry.txPackets = reader.nextLong();
+
+                stats.insertEntry(entry);
+                reader.finishLine();
+            }
+        } catch (NullPointerException|NumberFormatException e) {
+            throw protocolExceptionWithCause("problem parsing stats", e);
+        } finally {
+            IoUtils.closeQuietly(reader);
+            StrictMode.setThreadPolicy(savedPolicy);
+        }
+        return stats;
+    }
+
+    public NetworkStats readNetworkStatsDetail() throws IOException {
+        return readNetworkStatsDetail(UID_ALL, INTERFACES_ALL, TAG_ALL);
+    }
+
+    @GuardedBy("mPersistentDataLock")
+    private void requestSwapActiveStatsMapLocked() throws IOException {
+        try {
+            // Do a active map stats swap. Once the swap completes, this code
+            // can read and clean the inactive map without races.
+            mBpfNetMaps.swapActiveStatsMap();
+        } catch (ServiceSpecificException e) {
+            throw new IOException(e);
+        }
+    }
+
+    /**
+     * Reads the detailed UID stats based on the provided parameters
+     *
+     * @param limitUid the UID to limit this query to
+     * @param limitIfaces the interfaces to limit this query to. Use {@link
+     *     NetworkStats.INTERFACES_ALL} to select all interfaces
+     * @param limitTag the tags to limit this query to
+     * @return the NetworkStats instance containing network statistics at the present time.
+     */
+    public NetworkStats readNetworkStatsDetail(
+            int limitUid, String[] limitIfaces, int limitTag) throws IOException {
+        // In order to prevent deadlocks, anything protected by this lock MUST NOT call out to other
+        // code that will acquire other locks within the system server. See b/134244752.
+        synchronized (mPersistentDataLock) {
+            // Take a reference. If this gets swapped out, we still have the old reference.
+            final UnderlyingNetworkInfo[] vpnArray = mUnderlyingNetworkInfos;
+            // Take a defensive copy. mPersistSnapshot is mutated in some cases below
+            final NetworkStats prev = mPersistSnapshot.clone();
+
+            if (USE_NATIVE_PARSING) {
+                final NetworkStats stats =
+                        new NetworkStats(SystemClock.elapsedRealtime(), 0 /* initialSize */);
+                if (mUseBpfStats) {
+                    requestSwapActiveStatsMapLocked();
+                    // Stats are always read from the inactive map, so they must be read after the
+                    // swap
+                    if (nativeReadNetworkStatsDetail(stats, mStatsXtUid.getAbsolutePath(), UID_ALL,
+                            INTERFACES_ALL, TAG_ALL, mUseBpfStats) != 0) {
+                        throw new IOException("Failed to parse network stats");
+                    }
+
+                    // BPF stats are incremental; fold into mPersistSnapshot.
+                    mPersistSnapshot.setElapsedRealtime(stats.getElapsedRealtime());
+                    mPersistSnapshot.combineAllValues(stats);
+                } else {
+                    if (nativeReadNetworkStatsDetail(stats, mStatsXtUid.getAbsolutePath(), UID_ALL,
+                            INTERFACES_ALL, TAG_ALL, mUseBpfStats) != 0) {
+                        throw new IOException("Failed to parse network stats");
+                    }
+                    if (VALIDATE_NATIVE_STATS) {
+                        final NetworkStats javaStats = javaReadNetworkStatsDetail(mStatsXtUid,
+                                UID_ALL, INTERFACES_ALL, TAG_ALL);
+                        assertEquals(javaStats, stats);
+                    }
+
+                    mPersistSnapshot = stats;
+                }
+            } else {
+                mPersistSnapshot = javaReadNetworkStatsDetail(mStatsXtUid, UID_ALL, INTERFACES_ALL,
+                        TAG_ALL);
+            }
+
+            NetworkStats adjustedStats = adjustForTunAnd464Xlat(mPersistSnapshot, prev, vpnArray);
+
+            // Filter return values
+            adjustedStats.filter(limitUid, limitIfaces, limitTag);
+            return adjustedStats;
+        }
+    }
+
+    @GuardedBy("mPersistentDataLock")
+    private NetworkStats adjustForTunAnd464Xlat(NetworkStats uidDetailStats,
+            NetworkStats previousStats, UnderlyingNetworkInfo[] vpnArray) {
+        // Calculate delta from last snapshot
+        final NetworkStats delta = uidDetailStats.subtract(previousStats);
+
+        // Apply 464xlat adjustments before VPN adjustments. If VPNs are using v4 on a v6 only
+        // network, the overhead is their fault.
+        // No locking here: apply464xlatAdjustments behaves fine with an add-only
+        // ConcurrentHashMap.
+        delta.apply464xlatAdjustments(mStackedIfaces);
+
+        // Migrate data usage over a VPN to the TUN network.
+        for (UnderlyingNetworkInfo info : vpnArray) {
+            delta.migrateTun(info.getOwnerUid(), info.getInterface(),
+                    info.getUnderlyingInterfaces());
+            // Filter out debug entries as that may lead to over counting.
+            delta.filterDebugEntries();
+        }
+
+        // Update mTunAnd464xlatAdjustedStats with migrated delta.
+        mTunAnd464xlatAdjustedStats.combineAllValues(delta);
+        mTunAnd464xlatAdjustedStats.setElapsedRealtime(uidDetailStats.getElapsedRealtime());
+
+        return mTunAnd464xlatAdjustedStats.clone();
+    }
+
+    /**
+     * Parse and return {@link NetworkStats} with UID-level details. Values are
+     * expected to monotonically increase since device boot.
+     */
+    @VisibleForTesting
+    public static NetworkStats javaReadNetworkStatsDetail(File detailPath, int limitUid,
+            String[] limitIfaces, int limitTag)
+            throws IOException {
+        final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+
+        final NetworkStats stats = new NetworkStats(SystemClock.elapsedRealtime(), 24);
+        final NetworkStats.Entry entry = new NetworkStats.Entry();
+
+        int idx = 1;
+        int lastIdx = 1;
+
+        ProcFileReader reader = null;
+        try {
+            // open and consume header line
+            reader = new ProcFileReader(new FileInputStream(detailPath));
+            reader.finishLine();
+
+            while (reader.hasMoreData()) {
+                idx = reader.nextInt();
+                if (idx != lastIdx + 1) {
+                    throw new ProtocolException(
+                            "inconsistent idx=" + idx + " after lastIdx=" + lastIdx);
+                }
+                lastIdx = idx;
+
+                entry.iface = reader.nextString();
+                entry.tag = kernelToTag(reader.nextString());
+                entry.uid = reader.nextInt();
+                entry.set = reader.nextInt();
+                entry.rxBytes = reader.nextLong();
+                entry.rxPackets = reader.nextLong();
+                entry.txBytes = reader.nextLong();
+                entry.txPackets = reader.nextLong();
+
+                if ((limitIfaces == null || CollectionUtils.contains(limitIfaces, entry.iface))
+                        && (limitUid == UID_ALL || limitUid == entry.uid)
+                        && (limitTag == TAG_ALL || limitTag == entry.tag)) {
+                    stats.insertEntry(entry);
+                }
+
+                reader.finishLine();
+            }
+        } catch (NullPointerException|NumberFormatException e) {
+            throw protocolExceptionWithCause("problem parsing idx " + idx, e);
+        } finally {
+            IoUtils.closeQuietly(reader);
+            StrictMode.setThreadPolicy(savedPolicy);
+        }
+
+        return stats;
+    }
+
+    public void assertEquals(NetworkStats expected, NetworkStats actual) {
+        if (expected.size() != actual.size()) {
+            throw new AssertionError(
+                    "Expected size " + expected.size() + ", actual size " + actual.size());
+        }
+
+        NetworkStats.Entry expectedRow = null;
+        NetworkStats.Entry actualRow = null;
+        for (int i = 0; i < expected.size(); i++) {
+            expectedRow = expected.getValues(i, expectedRow);
+            actualRow = actual.getValues(i, actualRow);
+            if (!expectedRow.equals(actualRow)) {
+                throw new AssertionError(
+                        "Expected row " + i + ": " + expectedRow + ", actual row " + actualRow);
+            }
+        }
+    }
+
+    /**
+     * Convert {@code /proc/} tag format to {@link Integer}. Assumes incoming
+     * format like {@code 0x7fffffff00000000}.
+     */
+    public static int kernelToTag(String string) {
+        int length = string.length();
+        if (length > 10) {
+            return Long.decode(string.substring(0, length - 8)).intValue();
+        } else {
+            return 0;
+        }
+    }
+
+    /**
+     * Parse statistics from file into given {@link NetworkStats} object. Values
+     * are expected to monotonically increase since device boot.
+     */
+    @VisibleForTesting
+    public static native int nativeReadNetworkStatsDetail(NetworkStats stats, String path,
+        int limitUid, String[] limitIfaces, int limitTag, boolean useBpfStats);
+
+    @VisibleForTesting
+    public static native int nativeReadNetworkStatsDev(NetworkStats stats);
+
+    private static ProtocolException protocolExceptionWithCause(String message, Throwable cause) {
+        ProtocolException pe = new ProtocolException(message);
+        pe.initCause(cause);
+        return pe;
+    }
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsObservers.java b/service-t/src/com/android/server/net/NetworkStatsObservers.java
new file mode 100644
index 0000000..fdfc893
--- /dev/null
+++ b/service-t/src/com/android/server/net/NetworkStatsObservers.java
@@ -0,0 +1,451 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.net;
+
+import static android.app.usage.NetworkStatsManager.MIN_THRESHOLD_BYTES;
+
+import android.app.usage.NetworkStatsManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.net.DataUsageRequest;
+import android.net.NetworkIdentitySet;
+import android.net.NetworkStack;
+import android.net.NetworkStats;
+import android.net.NetworkStatsAccess;
+import android.net.NetworkStatsCollection;
+import android.net.NetworkStatsHistory;
+import android.net.NetworkTemplate;
+import android.net.netstats.IUsageCallback;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Process;
+import android.os.RemoteException;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Manages observers of {@link NetworkStats}. Allows observers to be notified when
+ * data usage has been reported in {@link NetworkStatsService}. An observer can set
+ * a threshold of how much data it cares about to be notified.
+ */
+class NetworkStatsObservers {
+    private static final String TAG = "NetworkStatsObservers";
+    private static final boolean LOGV = false;
+
+    private static final int MSG_REGISTER = 1;
+    private static final int MSG_UNREGISTER = 2;
+    private static final int MSG_UPDATE_STATS = 3;
+
+    // All access to this map must be done from the handler thread.
+    // indexed by DataUsageRequest#requestId
+    private final SparseArray<RequestInfo> mDataUsageRequests = new SparseArray<>();
+
+    // Sequence number of DataUsageRequests
+    private final AtomicInteger mNextDataUsageRequestId = new AtomicInteger();
+
+    // Lazily instantiated when an observer is registered.
+    private volatile Handler mHandler;
+
+    /**
+     * Creates a wrapper that contains the caller context and a normalized request.
+     * The request should be returned to the caller app, and the wrapper should be sent to this
+     * object through #addObserver by the service handler.
+     *
+     * <p>It will register the observer asynchronously, so it is safe to call from any thread.
+     *
+     * @return the normalized request wrapped within {@link RequestInfo}.
+     */
+    public DataUsageRequest register(Context context, DataUsageRequest inputRequest,
+            IUsageCallback callback, int callingUid, @NetworkStatsAccess.Level int accessLevel) {
+        DataUsageRequest request = buildRequest(context, inputRequest, callingUid);
+        RequestInfo requestInfo = buildRequestInfo(request, callback, callingUid,
+                accessLevel);
+
+        if (LOGV) Log.v(TAG, "Registering observer for " + request);
+        getHandler().sendMessage(mHandler.obtainMessage(MSG_REGISTER, requestInfo));
+        return request;
+    }
+
+    /**
+     * Unregister a data usage observer.
+     *
+     * <p>It will unregister the observer asynchronously, so it is safe to call from any thread.
+     */
+    public void unregister(DataUsageRequest request, int callingUid) {
+        getHandler().sendMessage(mHandler.obtainMessage(MSG_UNREGISTER, callingUid, 0 /* ignore */,
+                request));
+    }
+
+    /**
+     * Updates data usage statistics of registered observers and notifies if limits are reached.
+     *
+     * <p>It will update stats asynchronously, so it is safe to call from any thread.
+     */
+    public void updateStats(NetworkStats xtSnapshot, NetworkStats uidSnapshot,
+                ArrayMap<String, NetworkIdentitySet> activeIfaces,
+                ArrayMap<String, NetworkIdentitySet> activeUidIfaces,
+                long currentTime) {
+        StatsContext statsContext = new StatsContext(xtSnapshot, uidSnapshot, activeIfaces,
+                activeUidIfaces, currentTime);
+        getHandler().sendMessage(mHandler.obtainMessage(MSG_UPDATE_STATS, statsContext));
+    }
+
+    private Handler getHandler() {
+        if (mHandler == null) {
+            synchronized (this) {
+                if (mHandler == null) {
+                    if (LOGV) Log.v(TAG, "Creating handler");
+                    mHandler = new Handler(getHandlerLooperLocked(), mHandlerCallback);
+                }
+            }
+        }
+        return mHandler;
+    }
+
+    @VisibleForTesting
+    protected Looper getHandlerLooperLocked() {
+        HandlerThread handlerThread = new HandlerThread(TAG);
+        handlerThread.start();
+        return handlerThread.getLooper();
+    }
+
+    private Handler.Callback mHandlerCallback = new Handler.Callback() {
+        @Override
+        public boolean handleMessage(Message msg) {
+            switch (msg.what) {
+                case MSG_REGISTER: {
+                    handleRegister((RequestInfo) msg.obj);
+                    return true;
+                }
+                case MSG_UNREGISTER: {
+                    handleUnregister((DataUsageRequest) msg.obj, msg.arg1 /* callingUid */);
+                    return true;
+                }
+                case MSG_UPDATE_STATS: {
+                    handleUpdateStats((StatsContext) msg.obj);
+                    return true;
+                }
+                default: {
+                    return false;
+                }
+            }
+        }
+    };
+
+    /**
+     * Adds a {@link RequestInfo} as an observer.
+     * Should only be called from the handler thread otherwise there will be a race condition
+     * on mDataUsageRequests.
+     */
+    private void handleRegister(RequestInfo requestInfo) {
+        mDataUsageRequests.put(requestInfo.mRequest.requestId, requestInfo);
+    }
+
+    /**
+     * Removes a {@link DataUsageRequest} if the calling uid is authorized.
+     * Should only be called from the handler thread otherwise there will be a race condition
+     * on mDataUsageRequests.
+     */
+    private void handleUnregister(DataUsageRequest request, int callingUid) {
+        RequestInfo requestInfo;
+        requestInfo = mDataUsageRequests.get(request.requestId);
+        if (requestInfo == null) {
+            if (LOGV) Log.v(TAG, "Trying to unregister unknown request " + request);
+            return;
+        }
+        if (Process.SYSTEM_UID != callingUid && requestInfo.mCallingUid != callingUid) {
+            Log.w(TAG, "Caller uid " + callingUid + " is not owner of " + request);
+            return;
+        }
+
+        if (LOGV) Log.v(TAG, "Unregistering " + request);
+        mDataUsageRequests.remove(request.requestId);
+        requestInfo.unlinkDeathRecipient();
+        requestInfo.callCallback(NetworkStatsManager.CALLBACK_RELEASED);
+    }
+
+    private void handleUpdateStats(StatsContext statsContext) {
+        if (mDataUsageRequests.size() == 0) {
+            return;
+        }
+
+        for (int i = 0; i < mDataUsageRequests.size(); i++) {
+            RequestInfo requestInfo = mDataUsageRequests.valueAt(i);
+            requestInfo.updateStats(statsContext);
+        }
+    }
+
+    private DataUsageRequest buildRequest(Context context, DataUsageRequest request,
+                int callingUid) {
+        // For non-NETWORK_STACK permission uid, cap the minimum threshold to a safe default to
+        // avoid too many callbacks.
+        final long thresholdInBytes = (context.checkPermission(
+                NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, Process.myPid(), callingUid)
+                == PackageManager.PERMISSION_GRANTED ? request.thresholdInBytes
+                : Math.max(MIN_THRESHOLD_BYTES, request.thresholdInBytes));
+        if (thresholdInBytes > request.thresholdInBytes) {
+            Log.w(TAG, "Threshold was too low for " + request
+                    + ". Overriding to a safer default of " + thresholdInBytes + " bytes");
+        }
+        return new DataUsageRequest(mNextDataUsageRequestId.incrementAndGet(),
+                request.template, thresholdInBytes);
+    }
+
+    private RequestInfo buildRequestInfo(DataUsageRequest request, IUsageCallback callback,
+            int callingUid, @NetworkStatsAccess.Level int accessLevel) {
+        if (accessLevel <= NetworkStatsAccess.Level.USER) {
+            return new UserUsageRequestInfo(this, request, callback, callingUid,
+                    accessLevel);
+        } else {
+            // Safety check in case a new access level is added and we forgot to update this
+            if (accessLevel < NetworkStatsAccess.Level.DEVICESUMMARY) {
+                throw new IllegalArgumentException(
+                        "accessLevel " + accessLevel + " is less than DEVICESUMMARY.");
+            }
+            return new NetworkUsageRequestInfo(this, request, callback, callingUid,
+                    accessLevel);
+        }
+    }
+
+    /**
+     * Tracks information relevant to a data usage observer.
+     * It will notice when the calling process dies so we can self-expire.
+     */
+    private abstract static class RequestInfo implements IBinder.DeathRecipient {
+        private final NetworkStatsObservers mStatsObserver;
+        protected final DataUsageRequest mRequest;
+        private final IUsageCallback mCallback;
+        protected final int mCallingUid;
+        protected final @NetworkStatsAccess.Level int mAccessLevel;
+        protected NetworkStatsRecorder mRecorder;
+        protected NetworkStatsCollection mCollection;
+
+        RequestInfo(NetworkStatsObservers statsObserver, DataUsageRequest request,
+                IUsageCallback callback, int callingUid,
+                    @NetworkStatsAccess.Level int accessLevel) {
+            mStatsObserver = statsObserver;
+            mRequest = request;
+            mCallback = callback;
+            mCallingUid = callingUid;
+            mAccessLevel = accessLevel;
+
+            try {
+                mCallback.asBinder().linkToDeath(this, 0);
+            } catch (RemoteException e) {
+                binderDied();
+            }
+        }
+
+        @Override
+        public void binderDied() {
+            if (LOGV) {
+                Log.v(TAG, "RequestInfo binderDied(" + mRequest + ", " + mCallback + ")");
+            }
+            mStatsObserver.unregister(mRequest, Process.SYSTEM_UID);
+            callCallback(NetworkStatsManager.CALLBACK_RELEASED);
+        }
+
+        @Override
+        public String toString() {
+            return "RequestInfo from uid:" + mCallingUid
+                    + " for " + mRequest + " accessLevel:" + mAccessLevel;
+        }
+
+        private void unlinkDeathRecipient() {
+            mCallback.asBinder().unlinkToDeath(this, 0);
+        }
+
+        /**
+         * Update stats given the samples and interface to identity mappings.
+         */
+        private void updateStats(StatsContext statsContext) {
+            if (mRecorder == null) {
+                // First run; establish baseline stats
+                resetRecorder();
+                recordSample(statsContext);
+                return;
+            }
+            recordSample(statsContext);
+
+            if (checkStats()) {
+                resetRecorder();
+                callCallback(NetworkStatsManager.CALLBACK_LIMIT_REACHED);
+            }
+        }
+
+        private void callCallback(int callbackType) {
+            try {
+                if (LOGV) {
+                    Log.v(TAG, "sending notification " + callbackTypeToName(callbackType)
+                            + " for " + mRequest);
+                }
+                switch (callbackType) {
+                    case NetworkStatsManager.CALLBACK_LIMIT_REACHED:
+                        mCallback.onThresholdReached(mRequest);
+                        break;
+                    case NetworkStatsManager.CALLBACK_RELEASED:
+                        mCallback.onCallbackReleased(mRequest);
+                        break;
+                }
+            } catch (RemoteException e) {
+                // May occur naturally in the race of binder death.
+                Log.w(TAG, "RemoteException caught trying to send a callback msg for " + mRequest);
+            }
+        }
+
+        private void resetRecorder() {
+            mRecorder = new NetworkStatsRecorder();
+            mCollection = mRecorder.getSinceBoot();
+        }
+
+        protected abstract boolean checkStats();
+
+        protected abstract void recordSample(StatsContext statsContext);
+
+        private String callbackTypeToName(int callbackType) {
+            switch (callbackType) {
+                case NetworkStatsManager.CALLBACK_LIMIT_REACHED:
+                    return "LIMIT_REACHED";
+                case NetworkStatsManager.CALLBACK_RELEASED:
+                    return "RELEASED";
+                default:
+                    return "UNKNOWN";
+            }
+        }
+    }
+
+    private static class NetworkUsageRequestInfo extends RequestInfo {
+        NetworkUsageRequestInfo(NetworkStatsObservers statsObserver, DataUsageRequest request,
+                IUsageCallback callback, int callingUid,
+                    @NetworkStatsAccess.Level int accessLevel) {
+            super(statsObserver, request, callback, callingUid, accessLevel);
+        }
+
+        @Override
+        protected boolean checkStats() {
+            long bytesSoFar = getTotalBytesForNetwork(mRequest.template);
+            if (LOGV) {
+                Log.v(TAG, bytesSoFar + " bytes so far since notification for "
+                        + mRequest.template);
+            }
+            if (bytesSoFar > mRequest.thresholdInBytes) {
+                return true;
+            }
+            return false;
+        }
+
+        @Override
+        protected void recordSample(StatsContext statsContext) {
+            // Recorder does not need to be locked in this context since only the handler
+            // thread will update it. We pass a null VPN array because usage is aggregated by uid
+            // for this snapshot, so VPN traffic can't be reattributed to responsible apps.
+            mRecorder.recordSnapshotLocked(statsContext.mXtSnapshot, statsContext.mActiveIfaces,
+                    statsContext.mCurrentTime);
+        }
+
+        /**
+         * Reads stats matching the given template. {@link NetworkStatsCollection} will aggregate
+         * over all buckets, which in this case should be only one since we built it big enough
+         * that it will outlive the caller. If it doesn't, then there will be multiple buckets.
+         */
+        private long getTotalBytesForNetwork(NetworkTemplate template) {
+            NetworkStats stats = mCollection.getSummary(template,
+                    Long.MIN_VALUE /* start */, Long.MAX_VALUE /* end */,
+                    mAccessLevel, mCallingUid);
+            return stats.getTotalBytes();
+        }
+    }
+
+    private static class UserUsageRequestInfo extends RequestInfo {
+        UserUsageRequestInfo(NetworkStatsObservers statsObserver, DataUsageRequest request,
+                    IUsageCallback callback, int callingUid,
+                    @NetworkStatsAccess.Level int accessLevel) {
+            super(statsObserver, request, callback, callingUid, accessLevel);
+        }
+
+        @Override
+        protected boolean checkStats() {
+            int[] uidsToMonitor = mCollection.getRelevantUids(mAccessLevel, mCallingUid);
+
+            for (int i = 0; i < uidsToMonitor.length; i++) {
+                long bytesSoFar = getTotalBytesForNetworkUid(mRequest.template, uidsToMonitor[i]);
+                if (bytesSoFar > mRequest.thresholdInBytes) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        protected void recordSample(StatsContext statsContext) {
+            // Recorder does not need to be locked in this context since only the handler
+            // thread will update it. We pass the VPN info so VPN traffic is reattributed to
+            // responsible apps.
+            mRecorder.recordSnapshotLocked(statsContext.mUidSnapshot, statsContext.mActiveUidIfaces,
+                    statsContext.mCurrentTime);
+        }
+
+        /**
+         * Reads all stats matching the given template and uid. Ther history will likely only
+         * contain one bucket per ident since we build it big enough that it will outlive the
+         * caller lifetime.
+         */
+        private long getTotalBytesForNetworkUid(NetworkTemplate template, int uid) {
+            try {
+                NetworkStatsHistory history = mCollection.getHistory(template, null, uid,
+                        NetworkStats.SET_ALL, NetworkStats.TAG_NONE,
+                        NetworkStatsHistory.FIELD_ALL,
+                        Long.MIN_VALUE /* start */, Long.MAX_VALUE /* end */,
+                        mAccessLevel, mCallingUid);
+                return history.getTotalBytes();
+            } catch (SecurityException e) {
+                if (LOGV) {
+                    Log.w(TAG, "CallerUid " + mCallingUid + " may have lost access to uid "
+                            + uid);
+                }
+                return 0;
+            }
+        }
+    }
+
+    private static class StatsContext {
+        NetworkStats mXtSnapshot;
+        NetworkStats mUidSnapshot;
+        ArrayMap<String, NetworkIdentitySet> mActiveIfaces;
+        ArrayMap<String, NetworkIdentitySet> mActiveUidIfaces;
+        long mCurrentTime;
+
+        StatsContext(NetworkStats xtSnapshot, NetworkStats uidSnapshot,
+                ArrayMap<String, NetworkIdentitySet> activeIfaces,
+                ArrayMap<String, NetworkIdentitySet> activeUidIfaces,
+                long currentTime) {
+            mXtSnapshot = xtSnapshot;
+            mUidSnapshot = uidSnapshot;
+            mActiveIfaces = activeIfaces;
+            mActiveUidIfaces = activeUidIfaces;
+            mCurrentTime = currentTime;
+        }
+    }
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsRecorder.java b/service-t/src/com/android/server/net/NetworkStatsRecorder.java
new file mode 100644
index 0000000..f62765d
--- /dev/null
+++ b/service-t/src/com/android/server/net/NetworkStatsRecorder.java
@@ -0,0 +1,507 @@
+/*
+ * Copyright (C) 2012 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 android.net.NetworkStats.TAG_NONE;
+import static android.net.TrafficStats.KB_IN_BYTES;
+import static android.net.TrafficStats.MB_IN_BYTES;
+import static android.text.format.DateUtils.YEAR_IN_MILLIS;
+
+import android.net.NetworkIdentitySet;
+import android.net.NetworkStats;
+import android.net.NetworkStats.NonMonotonicObserver;
+import android.net.NetworkStatsAccess;
+import android.net.NetworkStatsCollection;
+import android.net.NetworkStatsHistory;
+import android.net.NetworkTemplate;
+import android.net.TrafficStats;
+import android.os.Binder;
+import android.os.DropBoxManager;
+import android.service.NetworkStatsRecorderProto;
+import android.util.IndentingPrintWriter;
+import android.util.Log;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.FileRotator;
+import com.android.net.module.util.NetworkStatsUtils;
+
+import libcore.io.IoUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.lang.ref.WeakReference;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Logic to record deltas between periodic {@link NetworkStats} snapshots into
+ * {@link NetworkStatsHistory} that belong to {@link NetworkStatsCollection}.
+ * Keeps pending changes in memory until they pass a specific threshold, in
+ * bytes. Uses {@link FileRotator} for persistence logic if present.
+ * <p>
+ * Not inherently thread safe.
+ */
+public class NetworkStatsRecorder {
+    private static final String TAG = "NetworkStatsRecorder";
+    private static final boolean LOGD = false;
+    private static final boolean LOGV = false;
+
+    private static final String TAG_NETSTATS_DUMP = "netstats_dump";
+
+    /** Dump before deleting in {@link #recoverFromWtf()}. */
+    private static final boolean DUMP_BEFORE_DELETE = true;
+
+    private final FileRotator mRotator;
+    private final NonMonotonicObserver<String> mObserver;
+    private final DropBoxManager mDropBox;
+    private final String mCookie;
+
+    private final long mBucketDuration;
+    private final boolean mOnlyTags;
+
+    private long mPersistThresholdBytes = 2 * MB_IN_BYTES;
+    private NetworkStats mLastSnapshot;
+
+    private final NetworkStatsCollection mPending;
+    private final NetworkStatsCollection mSinceBoot;
+
+    private final CombiningRewriter mPendingRewriter;
+
+    private WeakReference<NetworkStatsCollection> mComplete;
+
+    /**
+     * Non-persisted recorder, with only one bucket. Used by {@link NetworkStatsObservers}.
+     */
+    public NetworkStatsRecorder() {
+        mRotator = null;
+        mObserver = null;
+        mDropBox = null;
+        mCookie = null;
+
+        // set the bucket big enough to have all data in one bucket, but allow some
+        // slack to avoid overflow
+        mBucketDuration = YEAR_IN_MILLIS;
+        mOnlyTags = false;
+
+        mPending = null;
+        mSinceBoot = new NetworkStatsCollection(mBucketDuration);
+
+        mPendingRewriter = null;
+    }
+
+    /**
+     * Persisted recorder.
+     */
+    public NetworkStatsRecorder(FileRotator rotator, NonMonotonicObserver<String> observer,
+            DropBoxManager dropBox, String cookie, long bucketDuration, boolean onlyTags) {
+        mRotator = Objects.requireNonNull(rotator, "missing FileRotator");
+        mObserver = Objects.requireNonNull(observer, "missing NonMonotonicObserver");
+        mDropBox = Objects.requireNonNull(dropBox, "missing DropBoxManager");
+        mCookie = cookie;
+
+        mBucketDuration = bucketDuration;
+        mOnlyTags = onlyTags;
+
+        mPending = new NetworkStatsCollection(bucketDuration);
+        mSinceBoot = new NetworkStatsCollection(bucketDuration);
+
+        mPendingRewriter = new CombiningRewriter(mPending);
+    }
+
+    public void setPersistThreshold(long thresholdBytes) {
+        if (LOGV) Log.v(TAG, "setPersistThreshold() with " + thresholdBytes);
+        mPersistThresholdBytes = NetworkStatsUtils.constrain(
+                thresholdBytes, 1 * KB_IN_BYTES, 100 * MB_IN_BYTES);
+    }
+
+    public void resetLocked() {
+        mLastSnapshot = null;
+        if (mPending != null) {
+            mPending.reset();
+        }
+        if (mSinceBoot != null) {
+            mSinceBoot.reset();
+        }
+        if (mComplete != null) {
+            mComplete.clear();
+        }
+    }
+
+    public NetworkStats.Entry getTotalSinceBootLocked(NetworkTemplate template) {
+        return mSinceBoot.getSummary(template, Long.MIN_VALUE, Long.MAX_VALUE,
+                NetworkStatsAccess.Level.DEVICE, Binder.getCallingUid()).getTotal(null);
+    }
+
+    public NetworkStatsCollection getSinceBoot() {
+        return mSinceBoot;
+    }
+
+    /**
+     * Load complete history represented by {@link FileRotator}. Caches
+     * internally as a {@link WeakReference}, and updated with future
+     * {@link #recordSnapshotLocked(NetworkStats, Map, long)} snapshots as long
+     * as reference is valid.
+     */
+    public NetworkStatsCollection getOrLoadCompleteLocked() {
+        Objects.requireNonNull(mRotator, "missing FileRotator");
+        NetworkStatsCollection res = mComplete != null ? mComplete.get() : null;
+        if (res == null) {
+            res = loadLocked(Long.MIN_VALUE, Long.MAX_VALUE);
+            mComplete = new WeakReference<NetworkStatsCollection>(res);
+        }
+        return res;
+    }
+
+    public NetworkStatsCollection getOrLoadPartialLocked(long start, long end) {
+        Objects.requireNonNull(mRotator, "missing FileRotator");
+        NetworkStatsCollection res = mComplete != null ? mComplete.get() : null;
+        if (res == null) {
+            res = loadLocked(start, end);
+        }
+        return res;
+    }
+
+    private NetworkStatsCollection loadLocked(long start, long end) {
+        if (LOGD) Log.d(TAG, "loadLocked() reading from disk for " + mCookie);
+        final NetworkStatsCollection res = new NetworkStatsCollection(mBucketDuration);
+        try {
+            mRotator.readMatching(res, start, end);
+            res.recordCollection(mPending);
+        } catch (IOException e) {
+            Log.wtf(TAG, "problem completely reading network stats", e);
+            recoverFromWtf();
+        } catch (OutOfMemoryError e) {
+            Log.wtf(TAG, "problem completely reading network stats", e);
+            recoverFromWtf();
+        }
+        return res;
+    }
+
+    /**
+     * Record any delta that occurred since last {@link NetworkStats} snapshot, using the given
+     * {@link Map} to identify network interfaces. First snapshot is considered bootstrap, and is
+     * not counted as delta.
+     */
+    public void recordSnapshotLocked(NetworkStats snapshot,
+            Map<String, NetworkIdentitySet> ifaceIdent, long currentTimeMillis) {
+        final HashSet<String> unknownIfaces = new HashSet<>();
+
+        // skip recording when snapshot missing
+        if (snapshot == null) return;
+
+        // assume first snapshot is bootstrap and don't record
+        if (mLastSnapshot == null) {
+            mLastSnapshot = snapshot;
+            return;
+        }
+
+        final NetworkStatsCollection complete = mComplete != null ? mComplete.get() : null;
+
+        final NetworkStats delta = NetworkStats.subtract(
+                snapshot, mLastSnapshot, mObserver, mCookie);
+        final long end = currentTimeMillis;
+        final long start = end - delta.getElapsedRealtime();
+
+        NetworkStats.Entry entry = null;
+        for (int i = 0; i < delta.size(); i++) {
+            entry = delta.getValues(i, entry);
+
+            // As a last-ditch check, report any negative values and
+            // clamp them so recording below doesn't croak.
+            if (entry.isNegative()) {
+                if (mObserver != null) {
+                    mObserver.foundNonMonotonic(delta, i, mCookie);
+                }
+                entry.rxBytes = Math.max(entry.rxBytes, 0);
+                entry.rxPackets = Math.max(entry.rxPackets, 0);
+                entry.txBytes = Math.max(entry.txBytes, 0);
+                entry.txPackets = Math.max(entry.txPackets, 0);
+                entry.operations = Math.max(entry.operations, 0);
+            }
+
+            final NetworkIdentitySet ident = ifaceIdent.get(entry.iface);
+            if (ident == null) {
+                unknownIfaces.add(entry.iface);
+                continue;
+            }
+
+            // skip when no delta occurred
+            if (entry.isEmpty()) continue;
+
+            // only record tag data when requested
+            if ((entry.tag == TAG_NONE) != mOnlyTags) {
+                if (mPending != null) {
+                    mPending.recordData(ident, entry.uid, entry.set, entry.tag, start, end, entry);
+                }
+
+                // also record against boot stats when present
+                if (mSinceBoot != null) {
+                    mSinceBoot.recordData(ident, entry.uid, entry.set, entry.tag, start, end, entry);
+                }
+
+                // also record against complete dataset when present
+                if (complete != null) {
+                    complete.recordData(ident, entry.uid, entry.set, entry.tag, start, end, entry);
+                }
+            }
+        }
+
+        mLastSnapshot = snapshot;
+
+        if (LOGV && unknownIfaces.size() > 0) {
+            Log.w(TAG, "unknown interfaces " + unknownIfaces + ", ignoring those stats");
+        }
+    }
+
+    /**
+     * Consider persisting any pending deltas, if they are beyond
+     * {@link #mPersistThresholdBytes}.
+     */
+    public void maybePersistLocked(long currentTimeMillis) {
+        Objects.requireNonNull(mRotator, "missing FileRotator");
+        final long pendingBytes = mPending.getTotalBytes();
+        if (pendingBytes >= mPersistThresholdBytes) {
+            forcePersistLocked(currentTimeMillis);
+        } else {
+            mRotator.maybeRotate(currentTimeMillis);
+        }
+    }
+
+    /**
+     * Force persisting any pending deltas.
+     */
+    public void forcePersistLocked(long currentTimeMillis) {
+        Objects.requireNonNull(mRotator, "missing FileRotator");
+        if (mPending.isDirty()) {
+            if (LOGD) Log.d(TAG, "forcePersistLocked() writing for " + mCookie);
+            try {
+                mRotator.rewriteActive(mPendingRewriter, currentTimeMillis);
+                mRotator.maybeRotate(currentTimeMillis);
+                mPending.reset();
+            } catch (IOException e) {
+                Log.wtf(TAG, "problem persisting pending stats", e);
+                recoverFromWtf();
+            } catch (OutOfMemoryError e) {
+                Log.wtf(TAG, "problem persisting pending stats", e);
+                recoverFromWtf();
+            }
+        }
+    }
+
+    /**
+     * Remove the given UID from all {@link FileRotator} history, migrating it
+     * to {@link TrafficStats#UID_REMOVED}.
+     */
+    public void removeUidsLocked(int[] uids) {
+        if (mRotator != null) {
+            try {
+                // Rewrite all persisted data to migrate UID stats
+                mRotator.rewriteAll(new RemoveUidRewriter(mBucketDuration, uids));
+            } catch (IOException e) {
+                Log.wtf(TAG, "problem removing UIDs " + Arrays.toString(uids), e);
+                recoverFromWtf();
+            } catch (OutOfMemoryError e) {
+                Log.wtf(TAG, "problem removing UIDs " + Arrays.toString(uids), e);
+                recoverFromWtf();
+            }
+        }
+
+        // Remove any pending stats
+        if (mPending != null) {
+            mPending.removeUids(uids);
+        }
+        if (mSinceBoot != null) {
+            mSinceBoot.removeUids(uids);
+        }
+
+        // Clear UID from current stats snapshot
+        if (mLastSnapshot != null) {
+            mLastSnapshot.removeUids(uids);
+        }
+
+        final NetworkStatsCollection complete = mComplete != null ? mComplete.get() : null;
+        if (complete != null) {
+            complete.removeUids(uids);
+        }
+    }
+
+    /**
+     * Rewriter that will combine current {@link NetworkStatsCollection} values
+     * with anything read from disk, and write combined set to disk. Clears the
+     * original {@link NetworkStatsCollection} when finished writing.
+     */
+    private static class CombiningRewriter implements FileRotator.Rewriter {
+        private final NetworkStatsCollection mCollection;
+
+        public CombiningRewriter(NetworkStatsCollection collection) {
+            mCollection = Objects.requireNonNull(collection, "missing NetworkStatsCollection");
+        }
+
+        @Override
+        public void reset() {
+            // ignored
+        }
+
+        @Override
+        public void read(InputStream in) throws IOException {
+            mCollection.read(in);
+        }
+
+        @Override
+        public boolean shouldWrite() {
+            return true;
+        }
+
+        @Override
+        public void write(OutputStream out) throws IOException {
+            mCollection.write(out);
+            mCollection.reset();
+        }
+    }
+
+    /**
+     * Rewriter that will remove any {@link NetworkStatsHistory} attributed to
+     * the requested UID, only writing data back when modified.
+     */
+    public static class RemoveUidRewriter implements FileRotator.Rewriter {
+        private final NetworkStatsCollection mTemp;
+        private final int[] mUids;
+
+        public RemoveUidRewriter(long bucketDuration, int[] uids) {
+            mTemp = new NetworkStatsCollection(bucketDuration);
+            mUids = uids;
+        }
+
+        @Override
+        public void reset() {
+            mTemp.reset();
+        }
+
+        @Override
+        public void read(InputStream in) throws IOException {
+            mTemp.read(in);
+            mTemp.clearDirty();
+            mTemp.removeUids(mUids);
+        }
+
+        @Override
+        public boolean shouldWrite() {
+            return mTemp.isDirty();
+        }
+
+        @Override
+        public void write(OutputStream out) throws IOException {
+            mTemp.write(out);
+        }
+    }
+
+    public void importLegacyNetworkLocked(File file) throws IOException {
+        Objects.requireNonNull(mRotator, "missing FileRotator");
+
+        // legacy file still exists; start empty to avoid double importing
+        mRotator.deleteAll();
+
+        final NetworkStatsCollection collection = new NetworkStatsCollection(mBucketDuration);
+        collection.readLegacyNetwork(file);
+
+        final long startMillis = collection.getStartMillis();
+        final long endMillis = collection.getEndMillis();
+
+        if (!collection.isEmpty()) {
+            // process legacy data, creating active file at starting time, then
+            // using end time to possibly trigger rotation.
+            mRotator.rewriteActive(new CombiningRewriter(collection), startMillis);
+            mRotator.maybeRotate(endMillis);
+        }
+    }
+
+    public void importLegacyUidLocked(File file) throws IOException {
+        Objects.requireNonNull(mRotator, "missing FileRotator");
+
+        // legacy file still exists; start empty to avoid double importing
+        mRotator.deleteAll();
+
+        final NetworkStatsCollection collection = new NetworkStatsCollection(mBucketDuration);
+        collection.readLegacyUid(file, mOnlyTags);
+
+        final long startMillis = collection.getStartMillis();
+        final long endMillis = collection.getEndMillis();
+
+        if (!collection.isEmpty()) {
+            // process legacy data, creating active file at starting time, then
+            // using end time to possibly trigger rotation.
+            mRotator.rewriteActive(new CombiningRewriter(collection), startMillis);
+            mRotator.maybeRotate(endMillis);
+        }
+    }
+
+    public void dumpLocked(IndentingPrintWriter pw, boolean fullHistory) {
+        if (mPending != null) {
+            pw.print("Pending bytes: "); pw.println(mPending.getTotalBytes());
+        }
+        if (fullHistory) {
+            pw.println("Complete history:");
+            getOrLoadCompleteLocked().dump(pw);
+        } else {
+            pw.println("History since boot:");
+            mSinceBoot.dump(pw);
+        }
+    }
+
+    public void dumpDebugLocked(ProtoOutputStream proto, long tag) {
+        final long start = proto.start(tag);
+        if (mPending != null) {
+            proto.write(NetworkStatsRecorderProto.PENDING_TOTAL_BYTES,
+                    mPending.getTotalBytes());
+        }
+        getOrLoadCompleteLocked().dumpDebug(proto,
+                NetworkStatsRecorderProto.COMPLETE_HISTORY);
+        proto.end(start);
+    }
+
+    public void dumpCheckin(PrintWriter pw, long start, long end) {
+        // Only load and dump stats from the requested window
+        getOrLoadPartialLocked(start, end).dumpCheckin(pw, start, end);
+    }
+
+    /**
+     * Recover from {@link FileRotator} failure by dumping state to
+     * {@link DropBoxManager} and deleting contents.
+     */
+    private void recoverFromWtf() {
+        if (DUMP_BEFORE_DELETE) {
+            final ByteArrayOutputStream os = new ByteArrayOutputStream();
+            try {
+                mRotator.dumpAll(os);
+            } catch (IOException e) {
+                // ignore partial contents
+                os.reset();
+            } finally {
+                IoUtils.closeQuietly(os);
+            }
+            mDropBox.addData(TAG_NETSTATS_DUMP, os.toByteArray(), 0);
+        }
+
+        mRotator.deleteAll();
+    }
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
new file mode 100644
index 0000000..e3794e4
--- /dev/null
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -0,0 +1,2528 @@
+/*
+ * Copyright (C) 2011 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 android.Manifest.permission.NETWORK_STATS_PROVIDER;
+import static android.Manifest.permission.READ_NETWORK_USAGE_HISTORY;
+import static android.Manifest.permission.UPDATE_DEVICE_STATS;
+import static android.app.usage.NetworkStatsManager.PREFIX_DEV;
+import static android.content.Intent.ACTION_SHUTDOWN;
+import static android.content.Intent.ACTION_UID_REMOVED;
+import static android.content.Intent.ACTION_USER_REMOVED;
+import static android.content.Intent.EXTRA_UID;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.net.NetworkStats.DEFAULT_NETWORK_ALL;
+import static android.net.NetworkStats.IFACE_ALL;
+import static android.net.NetworkStats.IFACE_VT;
+import static android.net.NetworkStats.INTERFACES_ALL;
+import static android.net.NetworkStats.METERED_ALL;
+import static android.net.NetworkStats.ROAMING_ALL;
+import static android.net.NetworkStats.SET_ALL;
+import static android.net.NetworkStats.SET_DEFAULT;
+import static android.net.NetworkStats.SET_FOREGROUND;
+import static android.net.NetworkStats.STATS_PER_IFACE;
+import static android.net.NetworkStats.STATS_PER_UID;
+import static android.net.NetworkStats.TAG_ALL;
+import static android.net.NetworkStats.TAG_NONE;
+import static android.net.NetworkStats.UID_ALL;
+import static android.net.NetworkStatsHistory.FIELD_ALL;
+import static android.net.NetworkTemplate.buildTemplateMobileWildcard;
+import static android.net.NetworkTemplate.buildTemplateWifiWildcard;
+import static android.net.TrafficStats.KB_IN_BYTES;
+import static android.net.TrafficStats.MB_IN_BYTES;
+import static android.net.TrafficStats.UID_TETHERING;
+import static android.net.TrafficStats.UNSUPPORTED;
+import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
+import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
+import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_XT;
+import static android.os.Trace.TRACE_TAG_NETWORK;
+import static android.system.OsConstants.ENOENT;
+import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+import static android.text.format.DateUtils.DAY_IN_MILLIS;
+import static android.text.format.DateUtils.HOUR_IN_MILLIS;
+import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
+import static android.text.format.DateUtils.SECOND_IN_MILLIS;
+
+import static com.android.net.module.util.NetworkCapabilitiesUtils.getDisplayTransport;
+import static com.android.net.module.util.NetworkStatsUtils.LIMIT_GLOBAL_ALERT;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.TargetApi;
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.app.usage.NetworkStatsManager;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.database.ContentObserver;
+import android.net.DataUsageRequest;
+import android.net.INetd;
+import android.net.INetworkStatsService;
+import android.net.INetworkStatsSession;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkIdentity;
+import android.net.NetworkIdentitySet;
+import android.net.NetworkPolicyManager;
+import android.net.NetworkSpecifier;
+import android.net.NetworkStack;
+import android.net.NetworkStateSnapshot;
+import android.net.NetworkStats;
+import android.net.NetworkStats.NonMonotonicObserver;
+import android.net.NetworkStatsAccess;
+import android.net.NetworkStatsCollection;
+import android.net.NetworkStatsHistory;
+import android.net.NetworkTemplate;
+import android.net.TelephonyNetworkSpecifier;
+import android.net.TetherStatsParcel;
+import android.net.TetheringManager;
+import android.net.TrafficStats;
+import android.net.UnderlyingNetworkInfo;
+import android.net.Uri;
+import android.net.netstats.IUsageCallback;
+import android.net.netstats.provider.INetworkStatsProvider;
+import android.net.netstats.provider.INetworkStatsProviderCallback;
+import android.net.netstats.provider.NetworkStatsProvider;
+import android.os.Binder;
+import android.os.Build;
+import android.os.DropBoxManager;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.PowerManager;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.os.SystemClock;
+import android.os.Trace;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.provider.Settings.Global;
+import android.service.NetworkInterfaceProto;
+import android.service.NetworkStatsServiceDumpProto;
+import android.system.ErrnoException;
+import android.telephony.PhoneStateListener;
+import android.telephony.SubscriptionPlan;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.EventLog;
+import android.util.IndentingPrintWriter;
+import android.util.Log;
+import android.util.SparseIntArray;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.FileRotator;
+import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
+import com.android.net.module.util.BestClock;
+import com.android.net.module.util.BinderUtils;
+import com.android.net.module.util.BpfMap;
+import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.IBpfMap;
+import com.android.net.module.util.LocationPermissionChecker;
+import com.android.net.module.util.NetworkStatsUtils;
+import com.android.net.module.util.PermissionUtils;
+import com.android.net.module.util.Struct.U32;
+import com.android.net.module.util.Struct.U8;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.time.Clock;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Collect and persist detailed network statistics, and provide this data to
+ * other system services.
+ */
+@TargetApi(Build.VERSION_CODES.TIRAMISU)
+public class NetworkStatsService extends INetworkStatsService.Stub {
+    static {
+        System.loadLibrary("service-connectivity");
+    }
+
+    static final String TAG = "NetworkStats";
+    static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
+    static final boolean LOGV = Log.isLoggable(TAG, Log.VERBOSE);
+
+    // Perform polling and persist all (FLAG_PERSIST_ALL).
+    private static final int MSG_PERFORM_POLL = 1;
+    // Perform polling, persist network, and register the global alert again.
+    private static final int MSG_PERFORM_POLL_REGISTER_ALERT = 2;
+    private static final int MSG_NOTIFY_NETWORK_STATUS = 3;
+    // A message for broadcasting ACTION_NETWORK_STATS_UPDATED in handler thread to prevent
+    // deadlock.
+    private static final int MSG_BROADCAST_NETWORK_STATS_UPDATED = 4;
+
+    /** Flags to control detail level of poll event. */
+    private static final int FLAG_PERSIST_NETWORK = 0x1;
+    private static final int FLAG_PERSIST_UID = 0x2;
+    private static final int FLAG_PERSIST_ALL = FLAG_PERSIST_NETWORK | FLAG_PERSIST_UID;
+    private static final int FLAG_PERSIST_FORCE = 0x100;
+
+    /**
+     * When global alert quota is high, wait for this delay before processing each polling,
+     * and do not schedule further polls once there is already one queued.
+     * This avoids firing the global alert too often on devices with high transfer speeds and
+     * high quota.
+     */
+    private static final int DEFAULT_PERFORM_POLL_DELAY_MS = 1000;
+
+    private static final String TAG_NETSTATS_ERROR = "netstats_error";
+
+    /**
+     * EventLog tags used when logging into the event log. Note the values must be sync with
+     * frameworks/base/services/core/java/com/android/server/EventLogTags.logtags to get correct
+     * name translation.
+      */
+    private static final int LOG_TAG_NETSTATS_MOBILE_SAMPLE = 51100;
+    private static final int LOG_TAG_NETSTATS_WIFI_SAMPLE = 51101;
+
+    // TODO: Replace the hardcoded string and move it into ConnectivitySettingsManager.
+    private static final String NETSTATS_COMBINE_SUBTYPE_ENABLED =
+            "netstats_combine_subtype_enabled";
+
+    // This is current path but may be changed soon.
+    private static final String UID_COUNTERSET_MAP_PATH =
+            "/sys/fs/bpf/map_netd_uid_counterset_map";
+    private static final String COOKIE_TAG_MAP_PATH =
+            "/sys/fs/bpf/map_netd_cookie_tag_map";
+    private static final String APP_UID_STATS_MAP_PATH =
+            "/sys/fs/bpf/map_netd_app_uid_stats_map";
+    private static final String STATS_MAP_A_PATH =
+            "/sys/fs/bpf/map_netd_stats_map_A";
+    private static final String STATS_MAP_B_PATH =
+            "/sys/fs/bpf/map_netd_stats_map_B";
+
+    private final Context mContext;
+    private final NetworkStatsFactory mStatsFactory;
+    private final AlarmManager mAlarmManager;
+    private final Clock mClock;
+    private final NetworkStatsSettings mSettings;
+    private final NetworkStatsObservers mStatsObservers;
+
+    private final File mSystemDir;
+    private final File mBaseDir;
+
+    private final PowerManager.WakeLock mWakeLock;
+
+    private final ContentObserver mContentObserver;
+    private final ContentResolver mContentResolver;
+
+    protected INetd mNetd;
+    private final AlertObserver mAlertObserver = new AlertObserver();
+
+    @VisibleForTesting
+    public static final String ACTION_NETWORK_STATS_POLL =
+            "com.android.server.action.NETWORK_STATS_POLL";
+    public static final String ACTION_NETWORK_STATS_UPDATED =
+            "com.android.server.action.NETWORK_STATS_UPDATED";
+
+    private PendingIntent mPollIntent;
+
+    /**
+     * Settings that can be changed externally.
+     */
+    public interface NetworkStatsSettings {
+        long getPollInterval();
+        long getPollDelay();
+        boolean getSampleEnabled();
+        boolean getAugmentEnabled();
+        /**
+         * When enabled, all mobile data is reported under {@link NetworkTemplate#NETWORK_TYPE_ALL}.
+         * When disabled, mobile data is broken down by a granular ratType representative of the
+         * actual ratType. {@see android.app.usage.NetworkStatsManager#getCollapsedRatType}.
+         * Enabling this decreases the level of detail but saves performance, disk space and
+         * amount of data logged.
+         */
+        boolean getCombineSubtypeEnabled();
+
+        class Config {
+            public final long bucketDuration;
+            public final long rotateAgeMillis;
+            public final long deleteAgeMillis;
+
+            public Config(long bucketDuration, long rotateAgeMillis, long deleteAgeMillis) {
+                this.bucketDuration = bucketDuration;
+                this.rotateAgeMillis = rotateAgeMillis;
+                this.deleteAgeMillis = deleteAgeMillis;
+            }
+        }
+
+        Config getDevConfig();
+        Config getXtConfig();
+        Config getUidConfig();
+        Config getUidTagConfig();
+
+        long getGlobalAlertBytes(long def);
+        long getDevPersistBytes(long def);
+        long getXtPersistBytes(long def);
+        long getUidPersistBytes(long def);
+        long getUidTagPersistBytes(long def);
+    }
+
+    private final Object mStatsLock = new Object();
+
+    /** Set of currently active ifaces. */
+    @GuardedBy("mStatsLock")
+    private final ArrayMap<String, NetworkIdentitySet> mActiveIfaces = new ArrayMap<>();
+
+    /** Set of currently active ifaces for UID stats. */
+    @GuardedBy("mStatsLock")
+    private final ArrayMap<String, NetworkIdentitySet> mActiveUidIfaces = new ArrayMap<>();
+
+    /** Current default active iface. */
+    @GuardedBy("mStatsLock")
+    private String mActiveIface;
+
+    /** Set of any ifaces associated with mobile networks since boot. */
+    private volatile String[] mMobileIfaces = new String[0];
+
+    /** Set of any ifaces associated with wifi networks since boot. */
+    private volatile String[] mWifiIfaces = new String[0];
+
+    /** Set of all ifaces currently used by traffic that does not explicitly specify a Network. */
+    @GuardedBy("mStatsLock")
+    private Network[] mDefaultNetworks = new Network[0];
+
+    /** Last states of all networks sent from ConnectivityService. */
+    @GuardedBy("mStatsLock")
+    @Nullable
+    private NetworkStateSnapshot[] mLastNetworkStateSnapshots = null;
+
+    private final DropBoxNonMonotonicObserver mNonMonotonicObserver =
+            new DropBoxNonMonotonicObserver();
+
+    private static final int MAX_STATS_PROVIDER_POLL_WAIT_TIME_MS = 100;
+    private final CopyOnWriteArrayList<NetworkStatsProviderCallbackImpl> mStatsProviderCbList =
+            new CopyOnWriteArrayList<>();
+    /** Semaphore used to wait for stats provider to respond to request stats update. */
+    private final Semaphore mStatsProviderSem = new Semaphore(0, true);
+
+    @GuardedBy("mStatsLock")
+    private NetworkStatsRecorder mDevRecorder;
+    @GuardedBy("mStatsLock")
+    private NetworkStatsRecorder mXtRecorder;
+    @GuardedBy("mStatsLock")
+    private NetworkStatsRecorder mUidRecorder;
+    @GuardedBy("mStatsLock")
+    private NetworkStatsRecorder mUidTagRecorder;
+
+    /** Cached {@link #mXtRecorder} stats. */
+    @GuardedBy("mStatsLock")
+    private NetworkStatsCollection mXtStatsCached;
+
+    /**
+     * Current counter sets for each UID.
+     * TODO: maybe remove mActiveUidCounterSet and read UidCouneterSet value from mUidCounterSetMap
+     * directly ? But if mActiveUidCounterSet would be accessed very frequently, maybe keep
+     * mActiveUidCounterSet to avoid accessing kernel too frequently.
+     */
+    private SparseIntArray mActiveUidCounterSet = new SparseIntArray();
+    private final IBpfMap<U32, U8> mUidCounterSetMap;
+    private final IBpfMap<CookieTagMapKey, CookieTagMapValue> mCookieTagMap;
+    private final IBpfMap<StatsMapKey, StatsMapValue> mStatsMapA;
+    private final IBpfMap<StatsMapKey, StatsMapValue> mStatsMapB;
+    private final IBpfMap<UidStatsMapKey, StatsMapValue> mAppUidStatsMap;
+
+    /** Data layer operation counters for splicing into other structures. */
+    private NetworkStats mUidOperations = new NetworkStats(0L, 10);
+
+    @NonNull
+    private final Handler mHandler;
+
+    private volatile boolean mSystemReady;
+    private long mPersistThreshold = 2 * MB_IN_BYTES;
+    private long mGlobalAlertBytes;
+
+    private static final long POLL_RATE_LIMIT_MS = 15_000;
+
+    private long mLastStatsSessionPoll;
+
+    /** Map from UID to number of opened sessions */
+    @GuardedBy("mOpenSessionCallsPerUid")
+    private final SparseIntArray mOpenSessionCallsPerUid = new SparseIntArray();
+
+    private final static int DUMP_STATS_SESSION_COUNT = 20;
+
+    @NonNull
+    private final Dependencies mDeps;
+
+    @NonNull
+    private final NetworkStatsSubscriptionsMonitor mNetworkStatsSubscriptionsMonitor;
+
+    @NonNull
+    private final LocationPermissionChecker mLocationPermissionChecker;
+
+    @NonNull
+    private final BpfInterfaceMapUpdater mInterfaceMapUpdater;
+
+    private static @NonNull File getDefaultSystemDir() {
+        return new File(Environment.getDataDirectory(), "system");
+    }
+
+    private static @NonNull File getDefaultBaseDir() {
+        File baseDir = new File(getDefaultSystemDir(), "netstats");
+        baseDir.mkdirs();
+        return baseDir;
+    }
+
+    private static @NonNull Clock getDefaultClock() {
+        return new BestClock(ZoneOffset.UTC, SystemClock.currentNetworkTimeClock(),
+                Clock.systemUTC());
+    }
+
+    private final class NetworkStatsHandler extends Handler {
+        NetworkStatsHandler(@NonNull Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            switch (msg.what) {
+                case MSG_PERFORM_POLL: {
+                    performPoll(FLAG_PERSIST_ALL);
+                    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);
+                    break;
+                }
+                case MSG_PERFORM_POLL_REGISTER_ALERT: {
+                    performPoll(FLAG_PERSIST_NETWORK);
+                    registerGlobalAlert();
+                    break;
+                }
+                case MSG_BROADCAST_NETWORK_STATS_UPDATED: {
+                    final Intent updatedIntent = new Intent(ACTION_NETWORK_STATS_UPDATED);
+                    updatedIntent.setFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY);
+                    mContext.sendBroadcastAsUser(updatedIntent, UserHandle.ALL,
+                            READ_NETWORK_USAGE_HISTORY);
+                    break;
+                }
+            }
+        }
+    }
+
+    /** Creates a new NetworkStatsService */
+    public static NetworkStatsService create(Context context) {
+        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+        PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+        PowerManager.WakeLock wakeLock =
+                powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
+        final INetd netd = INetd.Stub.asInterface(
+                (IBinder) context.getSystemService(Context.NETD_SERVICE));
+        final NetworkStatsService service = new NetworkStatsService(context,
+                INetd.Stub.asInterface((IBinder) context.getSystemService(Context.NETD_SERVICE)),
+                alarmManager, wakeLock, getDefaultClock(),
+                new DefaultNetworkStatsSettings(), new NetworkStatsFactory(context),
+                new NetworkStatsObservers(), getDefaultSystemDir(), getDefaultBaseDir(),
+                new Dependencies());
+
+        return service;
+    }
+
+    // This must not be called outside of tests, even within the same package, as this constructor
+    // does not register the local service. Use the create() helper above.
+    @VisibleForTesting
+    NetworkStatsService(Context context, INetd netd, AlarmManager alarmManager,
+            PowerManager.WakeLock wakeLock, Clock clock, NetworkStatsSettings settings,
+            NetworkStatsFactory factory, NetworkStatsObservers statsObservers, File systemDir,
+            File baseDir, @NonNull Dependencies deps) {
+        mContext = Objects.requireNonNull(context, "missing Context");
+        mNetd = Objects.requireNonNull(netd, "missing Netd");
+        mAlarmManager = Objects.requireNonNull(alarmManager, "missing AlarmManager");
+        mClock = Objects.requireNonNull(clock, "missing Clock");
+        mSettings = Objects.requireNonNull(settings, "missing NetworkStatsSettings");
+        mWakeLock = Objects.requireNonNull(wakeLock, "missing WakeLock");
+        mStatsFactory = Objects.requireNonNull(factory, "missing factory");
+        mStatsObservers = Objects.requireNonNull(statsObservers, "missing NetworkStatsObservers");
+        mSystemDir = Objects.requireNonNull(systemDir, "missing systemDir");
+        mBaseDir = Objects.requireNonNull(baseDir, "missing baseDir");
+        mDeps = Objects.requireNonNull(deps, "missing Dependencies");
+
+        final HandlerThread handlerThread = mDeps.makeHandlerThread();
+        handlerThread.start();
+        mHandler = new NetworkStatsHandler(handlerThread.getLooper());
+        mNetworkStatsSubscriptionsMonitor = deps.makeSubscriptionsMonitor(mContext,
+                (command) -> mHandler.post(command) , this);
+        mContentResolver = mContext.getContentResolver();
+        mContentObserver = mDeps.makeContentObserver(mHandler, mSettings,
+                mNetworkStatsSubscriptionsMonitor);
+        mLocationPermissionChecker = mDeps.makeLocationPermissionChecker(mContext);
+        mInterfaceMapUpdater = mDeps.makeBpfInterfaceMapUpdater(mContext, mHandler);
+        mInterfaceMapUpdater.start();
+        mUidCounterSetMap = mDeps.getUidCounterSetMap();
+        mCookieTagMap = mDeps.getCookieTagMap();
+        mStatsMapA = mDeps.getStatsMapA();
+        mStatsMapB = mDeps.getStatsMapB();
+        mAppUidStatsMap = mDeps.getAppUidStatsMap();
+    }
+
+    /**
+     * Dependencies of NetworkStatsService, for injection in tests.
+     */
+    // TODO: Move more stuff into dependencies object.
+    @VisibleForTesting
+    public static class Dependencies {
+        /**
+         * Create a HandlerThread to use in NetworkStatsService.
+         */
+        @NonNull
+        public HandlerThread makeHandlerThread() {
+            return new HandlerThread(TAG);
+        }
+
+        /**
+         * Create a {@link NetworkStatsSubscriptionsMonitor}, can be used to monitor RAT change
+         * event in NetworkStatsService.
+         */
+        @NonNull
+        public NetworkStatsSubscriptionsMonitor makeSubscriptionsMonitor(@NonNull Context context,
+                @NonNull Executor executor, @NonNull NetworkStatsService service) {
+            // TODO: Update RatType passively in NSS, instead of querying into the monitor
+            //  when notifyNetworkStatus.
+            return new NetworkStatsSubscriptionsMonitor(context, executor,
+                    (subscriberId, type) -> service.handleOnCollapsedRatTypeChanged());
+        }
+
+        /**
+         * Create a ContentObserver instance which is used to observe settings changes,
+         * and dispatch onChange events on handler thread.
+         */
+        public @NonNull ContentObserver makeContentObserver(@NonNull Handler handler,
+                @NonNull NetworkStatsSettings settings,
+                @NonNull NetworkStatsSubscriptionsMonitor monitor) {
+            return new ContentObserver(handler) {
+                @Override
+                public void onChange(boolean selfChange, @NonNull Uri uri) {
+                    if (!settings.getCombineSubtypeEnabled()) {
+                        monitor.start();
+                    } else {
+                        monitor.stop();
+                    }
+                }
+            };
+        }
+
+        /**
+         * @see LocationPermissionChecker
+         */
+        public LocationPermissionChecker makeLocationPermissionChecker(final Context context) {
+            return new LocationPermissionChecker(context);
+        }
+
+        /** Create BpfInterfaceMapUpdater to update bpf interface map. */
+        @NonNull
+        public BpfInterfaceMapUpdater makeBpfInterfaceMapUpdater(
+                @NonNull Context ctx, @NonNull Handler handler) {
+            return new BpfInterfaceMapUpdater(ctx, handler);
+        }
+
+        /** Get counter sets map for each UID. */
+        public IBpfMap<U32, U8> getUidCounterSetMap() {
+            try {
+                return new BpfMap<U32, U8>(UID_COUNTERSET_MAP_PATH, BpfMap.BPF_F_RDWR,
+                        U32.class, U8.class);
+            } catch (ErrnoException e) {
+                Log.wtf(TAG, "Cannot open uid counter set map: " + e);
+                return null;
+            }
+        }
+
+        /** Gets the cookie tag map */
+        public IBpfMap<CookieTagMapKey, CookieTagMapValue> getCookieTagMap() {
+            try {
+                return new BpfMap<CookieTagMapKey, CookieTagMapValue>(COOKIE_TAG_MAP_PATH,
+                        BpfMap.BPF_F_RDWR, CookieTagMapKey.class, CookieTagMapValue.class);
+            } catch (ErrnoException e) {
+                Log.wtf(TAG, "Cannot open cookie tag map: " + e);
+                return null;
+            }
+        }
+
+        /** Gets stats map A */
+        public IBpfMap<StatsMapKey, StatsMapValue> getStatsMapA() {
+            try {
+                return new BpfMap<StatsMapKey, StatsMapValue>(STATS_MAP_A_PATH,
+                        BpfMap.BPF_F_RDWR, StatsMapKey.class, StatsMapValue.class);
+            } catch (ErrnoException e) {
+                Log.wtf(TAG, "Cannot open stats map A: " + e);
+                return null;
+            }
+        }
+
+        /** Gets stats map B */
+        public IBpfMap<StatsMapKey, StatsMapValue> getStatsMapB() {
+            try {
+                return new BpfMap<StatsMapKey, StatsMapValue>(STATS_MAP_B_PATH,
+                        BpfMap.BPF_F_RDWR, StatsMapKey.class, StatsMapValue.class);
+            } catch (ErrnoException e) {
+                Log.wtf(TAG, "Cannot open stats map B: " + e);
+                return null;
+            }
+        }
+
+        /** Gets the uid stats map */
+        public IBpfMap<UidStatsMapKey, StatsMapValue> getAppUidStatsMap() {
+            try {
+                return new BpfMap<UidStatsMapKey, StatsMapValue>(APP_UID_STATS_MAP_PATH,
+                        BpfMap.BPF_F_RDWR, UidStatsMapKey.class, StatsMapValue.class);
+            } catch (ErrnoException e) {
+                Log.wtf(TAG, "Cannot open app uid stats map: " + e);
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Observer that watches for {@link INetdUnsolicitedEventListener} alerts.
+     */
+    @VisibleForTesting
+    public class AlertObserver extends BaseNetdUnsolicitedEventListener {
+        @Override
+        public void onQuotaLimitReached(@NonNull String alertName, @NonNull String ifName) {
+            PermissionUtils.enforceNetworkStackPermission(mContext);
+
+            if (LIMIT_GLOBAL_ALERT.equals(alertName)) {
+                // kick off background poll to collect network stats unless there is already
+                // such a call pending; UID stats are handled during normal polling interval.
+                if (!mHandler.hasMessages(MSG_PERFORM_POLL_REGISTER_ALERT)) {
+                    mHandler.sendEmptyMessageDelayed(MSG_PERFORM_POLL_REGISTER_ALERT,
+                            mSettings.getPollDelay());
+                }
+            }
+        }
+    }
+
+    public void systemReady() {
+        synchronized (mStatsLock) {
+            mSystemReady = true;
+
+            // create data recorders along with historical rotators
+            mDevRecorder = buildRecorder(PREFIX_DEV, mSettings.getDevConfig(), false);
+            mXtRecorder = buildRecorder(PREFIX_XT, mSettings.getXtConfig(), false);
+            mUidRecorder = buildRecorder(PREFIX_UID, mSettings.getUidConfig(), false);
+            mUidTagRecorder = buildRecorder(PREFIX_UID_TAG, mSettings.getUidTagConfig(), true);
+
+            updatePersistThresholdsLocked();
+
+            // upgrade any legacy stats, migrating them to rotated files
+            maybeUpgradeLegacyStatsLocked();
+
+            // read historical network stats from disk, since policy service
+            // might need them right away.
+            mXtStatsCached = mXtRecorder.getOrLoadCompleteLocked();
+
+            // bootstrap initial stats to prevent double-counting later
+            bootstrapStatsLocked();
+        }
+
+        // watch for tethering changes
+        final TetheringManager tetheringManager = mContext.getSystemService(TetheringManager.class);
+        tetheringManager.registerTetheringEventCallback(
+                (command) -> mHandler.post(command), mTetherListener);
+
+        // listen for periodic polling events
+        final IntentFilter pollFilter = new IntentFilter(ACTION_NETWORK_STATS_POLL);
+        mContext.registerReceiver(mPollReceiver, pollFilter, READ_NETWORK_USAGE_HISTORY, mHandler);
+
+        // listen for uid removal to clean stats
+        final IntentFilter removedFilter = new IntentFilter(ACTION_UID_REMOVED);
+        mContext.registerReceiver(mRemovedReceiver, removedFilter, null, mHandler);
+
+        // listen for user changes to clean stats
+        final IntentFilter userFilter = new IntentFilter(ACTION_USER_REMOVED);
+        mContext.registerReceiver(mUserReceiver, userFilter, null, mHandler);
+
+        // persist stats during clean shutdown
+        final IntentFilter shutdownFilter = new IntentFilter(ACTION_SHUTDOWN);
+        mContext.registerReceiver(mShutdownReceiver, shutdownFilter);
+
+        try {
+            mNetd.registerUnsolicitedEventListener(mAlertObserver);
+        } catch (RemoteException | ServiceSpecificException e) {
+            Log.wtf(TAG, "Error registering event listener :", e);
+        }
+
+        //  schedule periodic pall alarm based on {@link NetworkStatsSettings#getPollInterval()}.
+        final PendingIntent pollIntent =
+                PendingIntent.getBroadcast(mContext, 0, new Intent(ACTION_NETWORK_STATS_POLL),
+                        PendingIntent.FLAG_IMMUTABLE);
+
+        final long currentRealtime = SystemClock.elapsedRealtime();
+        mAlarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, currentRealtime,
+                mSettings.getPollInterval(), pollIntent);
+
+        mContentResolver.registerContentObserver(Settings.Global
+                .getUriFor(NETSTATS_COMBINE_SUBTYPE_ENABLED),
+                        false /* notifyForDescendants */, mContentObserver);
+
+        // Post a runnable on handler thread to call onChange(). It's for getting current value of
+        // NETSTATS_COMBINE_SUBTYPE_ENABLED to decide start or stop monitoring RAT type changes.
+        mHandler.post(() -> mContentObserver.onChange(false, Settings.Global
+                .getUriFor(NETSTATS_COMBINE_SUBTYPE_ENABLED)));
+
+        registerGlobalAlert();
+    }
+
+    private NetworkStatsRecorder buildRecorder(
+            String prefix, NetworkStatsSettings.Config config, boolean includeTags) {
+        final DropBoxManager dropBox = (DropBoxManager) mContext.getSystemService(
+                Context.DROPBOX_SERVICE);
+        return new NetworkStatsRecorder(new FileRotator(
+                mBaseDir, prefix, config.rotateAgeMillis, config.deleteAgeMillis),
+                mNonMonotonicObserver, dropBox, prefix, config.bucketDuration, includeTags);
+    }
+
+    @GuardedBy("mStatsLock")
+    private void shutdownLocked() {
+        final TetheringManager tetheringManager = mContext.getSystemService(TetheringManager.class);
+        tetheringManager.unregisterTetheringEventCallback(mTetherListener);
+        mContext.unregisterReceiver(mPollReceiver);
+        mContext.unregisterReceiver(mRemovedReceiver);
+        mContext.unregisterReceiver(mUserReceiver);
+        mContext.unregisterReceiver(mShutdownReceiver);
+
+        if (!mSettings.getCombineSubtypeEnabled()) {
+            mNetworkStatsSubscriptionsMonitor.stop();
+        }
+
+        mContentResolver.unregisterContentObserver(mContentObserver);
+
+        final long currentTime = mClock.millis();
+
+        // persist any pending stats
+        mDevRecorder.forcePersistLocked(currentTime);
+        mXtRecorder.forcePersistLocked(currentTime);
+        mUidRecorder.forcePersistLocked(currentTime);
+        mUidTagRecorder.forcePersistLocked(currentTime);
+
+        mSystemReady = false;
+    }
+
+    @GuardedBy("mStatsLock")
+    private void maybeUpgradeLegacyStatsLocked() {
+        File file;
+        try {
+            file = new File(mSystemDir, "netstats.bin");
+            if (file.exists()) {
+                mDevRecorder.importLegacyNetworkLocked(file);
+                file.delete();
+            }
+
+            file = new File(mSystemDir, "netstats_xt.bin");
+            if (file.exists()) {
+                file.delete();
+            }
+
+            file = new File(mSystemDir, "netstats_uid.bin");
+            if (file.exists()) {
+                mUidRecorder.importLegacyUidLocked(file);
+                mUidTagRecorder.importLegacyUidLocked(file);
+                file.delete();
+            }
+        } catch (IOException e) {
+            Log.wtf(TAG, "problem during legacy upgrade", e);
+        } catch (OutOfMemoryError e) {
+            Log.wtf(TAG, "problem during legacy upgrade", e);
+        }
+    }
+
+    /**
+     * Register for a global alert that is delivered through {@link AlertObserver}
+     * or {@link NetworkStatsProviderCallback#onAlertReached()} once a threshold amount of data has
+     * been transferred.
+     */
+    private void registerGlobalAlert() {
+        try {
+            mNetd.bandwidthSetGlobalAlert(mGlobalAlertBytes);
+        } catch (IllegalStateException e) {
+            Log.w(TAG, "problem registering for global alert: " + e);
+        } catch (RemoteException e) {
+            // ignored; service lives in system_server
+        }
+        invokeForAllStatsProviderCallbacks((cb) -> cb.mProvider.onSetAlert(mGlobalAlertBytes));
+    }
+
+    @Override
+    public INetworkStatsSession openSession() {
+        return openSessionInternal(NetworkStatsManager.FLAG_AUGMENT_WITH_SUBSCRIPTION_PLAN, null);
+    }
+
+    @Override
+    public INetworkStatsSession openSessionForUsageStats(int flags, String callingPackage) {
+        return openSessionInternal(flags, callingPackage);
+    }
+
+    private boolean isRateLimitedForPoll(int callingUid) {
+        if (callingUid == android.os.Process.SYSTEM_UID) {
+            return false;
+        }
+
+        final long lastCallTime;
+        final long now = SystemClock.elapsedRealtime();
+        synchronized (mOpenSessionCallsPerUid) {
+            int calls = mOpenSessionCallsPerUid.get(callingUid, 0);
+            mOpenSessionCallsPerUid.put(callingUid, calls + 1);
+            lastCallTime = mLastStatsSessionPoll;
+            mLastStatsSessionPoll = now;
+        }
+
+        return now - lastCallTime < POLL_RATE_LIMIT_MS;
+    }
+
+    private int restrictFlagsForCaller(int flags) {
+        // All non-privileged callers are not allowed to turn off POLL_ON_OPEN.
+        final boolean isPrivileged = PermissionUtils.checkAnyPermissionOf(mContext,
+                NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+                android.Manifest.permission.NETWORK_STACK);
+        if (!isPrivileged) {
+            flags |= NetworkStatsManager.FLAG_POLL_ON_OPEN;
+        }
+        // Non-system uids are rate limited for POLL_ON_OPEN.
+        final int callingUid = Binder.getCallingUid();
+        flags = isRateLimitedForPoll(callingUid)
+                ? flags & (~NetworkStatsManager.FLAG_POLL_ON_OPEN)
+                : flags;
+        return flags;
+    }
+
+    private INetworkStatsSession openSessionInternal(final int flags, final String callingPackage) {
+        final int restrictedFlags = restrictFlagsForCaller(flags);
+        if ((restrictedFlags & (NetworkStatsManager.FLAG_POLL_ON_OPEN
+                | NetworkStatsManager.FLAG_POLL_FORCE)) != 0) {
+            final long ident = Binder.clearCallingIdentity();
+            try {
+                performPoll(FLAG_PERSIST_ALL);
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        // return an IBinder which holds strong references to any loaded stats
+        // for its lifetime; when caller closes only weak references remain.
+
+        return new INetworkStatsSession.Stub() {
+            private final int mCallingUid = Binder.getCallingUid();
+            private final String mCallingPackage = callingPackage;
+            private final @NetworkStatsAccess.Level int mAccessLevel = checkAccessLevel(
+                    callingPackage);
+
+            private NetworkStatsCollection mUidComplete;
+            private NetworkStatsCollection mUidTagComplete;
+
+            private NetworkStatsCollection getUidComplete() {
+                synchronized (mStatsLock) {
+                    if (mUidComplete == null) {
+                        mUidComplete = mUidRecorder.getOrLoadCompleteLocked();
+                    }
+                    return mUidComplete;
+                }
+            }
+
+            private NetworkStatsCollection getUidTagComplete() {
+                synchronized (mStatsLock) {
+                    if (mUidTagComplete == null) {
+                        mUidTagComplete = mUidTagRecorder.getOrLoadCompleteLocked();
+                    }
+                    return mUidTagComplete;
+                }
+            }
+
+            @Override
+            public int[] getRelevantUids() {
+                return getUidComplete().getRelevantUids(mAccessLevel);
+            }
+
+            @Override
+            public NetworkStats getDeviceSummaryForNetwork(
+                    NetworkTemplate template, long start, long end) {
+                enforceTemplatePermissions(template, callingPackage);
+                return internalGetSummaryForNetwork(template, restrictedFlags, start, end,
+                        mAccessLevel, mCallingUid);
+            }
+
+            @Override
+            public NetworkStats getSummaryForNetwork(
+                    NetworkTemplate template, long start, long end) {
+                enforceTemplatePermissions(template, callingPackage);
+                return internalGetSummaryForNetwork(template, restrictedFlags, start, end,
+                        mAccessLevel, mCallingUid);
+            }
+
+            // TODO: Remove this after all callers are removed.
+            @Override
+            public NetworkStatsHistory getHistoryForNetwork(NetworkTemplate template, int fields) {
+                enforceTemplatePermissions(template, callingPackage);
+                return internalGetHistoryForNetwork(template, restrictedFlags, fields,
+                        mAccessLevel, mCallingUid, Long.MIN_VALUE, Long.MAX_VALUE);
+            }
+
+            @Override
+            public NetworkStatsHistory getHistoryIntervalForNetwork(NetworkTemplate template,
+                    int fields, long start, long end) {
+                enforceTemplatePermissions(template, callingPackage);
+                // TODO(b/200768422): Redact returned history if the template is location
+                //  sensitive but the caller is not privileged.
+                return internalGetHistoryForNetwork(template, restrictedFlags, fields,
+                        mAccessLevel, mCallingUid, start, end);
+            }
+
+            @Override
+            public NetworkStats getSummaryForAllUid(
+                    NetworkTemplate template, long start, long end, boolean includeTags) {
+                enforceTemplatePermissions(template, callingPackage);
+                try {
+                    final NetworkStats stats = getUidComplete()
+                            .getSummary(template, start, end, mAccessLevel, mCallingUid);
+                    if (includeTags) {
+                        final NetworkStats tagStats = getUidTagComplete()
+                                .getSummary(template, start, end, mAccessLevel, mCallingUid);
+                        stats.combineAllValues(tagStats);
+                    }
+                    return stats;
+                } catch (NullPointerException e) {
+                    throw e;
+                }
+            }
+
+            @Override
+            public NetworkStats getTaggedSummaryForAllUid(
+                    NetworkTemplate template, long start, long end) {
+                enforceTemplatePermissions(template, callingPackage);
+                try {
+                    final NetworkStats tagStats = getUidTagComplete()
+                            .getSummary(template, start, end, mAccessLevel, mCallingUid);
+                    return tagStats;
+                } catch (NullPointerException e) {
+                    throw e;
+                }
+            }
+
+            @Override
+            public NetworkStatsHistory getHistoryForUid(
+                    NetworkTemplate template, int uid, int set, int tag, int fields) {
+                enforceTemplatePermissions(template, callingPackage);
+                // NOTE: We don't augment UID-level statistics
+                if (tag == TAG_NONE) {
+                    return getUidComplete().getHistory(template, null, uid, set, tag, fields,
+                            Long.MIN_VALUE, Long.MAX_VALUE, mAccessLevel, mCallingUid);
+                } else {
+                    return getUidTagComplete().getHistory(template, null, uid, set, tag, fields,
+                            Long.MIN_VALUE, Long.MAX_VALUE, mAccessLevel, mCallingUid);
+                }
+            }
+
+            @Override
+            public NetworkStatsHistory getHistoryIntervalForUid(
+                    NetworkTemplate template, int uid, int set, int tag, int fields,
+                    long start, long end) {
+                enforceTemplatePermissions(template, callingPackage);
+                // TODO(b/200768422): Redact returned history if the template is location
+                //  sensitive but the caller is not privileged.
+                // NOTE: We don't augment UID-level statistics
+                if (tag == TAG_NONE) {
+                    return getUidComplete().getHistory(template, null, uid, set, tag, fields,
+                            start, end, mAccessLevel, mCallingUid);
+                } else if (uid == Binder.getCallingUid()) {
+                    return getUidTagComplete().getHistory(template, null, uid, set, tag, fields,
+                            start, end, mAccessLevel, mCallingUid);
+                } else {
+                    throw new SecurityException("Calling package " + mCallingPackage
+                            + " cannot access tag information from a different uid");
+                }
+            }
+
+            @Override
+            public void close() {
+                mUidComplete = null;
+                mUidTagComplete = null;
+            }
+        };
+    }
+
+    private void enforceTemplatePermissions(@NonNull NetworkTemplate template,
+            @NonNull String callingPackage) {
+        // For a template with wifi network keys, it is possible for a malicious
+        // client to track the user locations via querying data usage. Thus, enforce
+        // fine location permission check.
+        if (!template.getWifiNetworkKeys().isEmpty()) {
+            final boolean canAccessFineLocation = mLocationPermissionChecker
+                    .checkCallersLocationPermission(callingPackage,
+                    null /* featureId */,
+                            Binder.getCallingUid(),
+                            false /* coarseForTargetSdkLessThanQ */,
+                            null /* message */);
+            if (!canAccessFineLocation) {
+                throw new SecurityException("Access fine location is required when querying"
+                        + " with wifi network keys, make sure the app has the necessary"
+                        + "permissions and the location toggle is on.");
+            }
+        }
+    }
+
+    private @NetworkStatsAccess.Level int checkAccessLevel(String callingPackage) {
+        return NetworkStatsAccess.checkAccessLevel(
+                mContext, Binder.getCallingPid(), Binder.getCallingUid(), callingPackage);
+    }
+
+    /**
+     * Find the most relevant {@link SubscriptionPlan} for the given
+     * {@link NetworkTemplate} and flags. This is typically used to augment
+     * local measurement results to match a known anchor from the carrier.
+     */
+    private SubscriptionPlan resolveSubscriptionPlan(NetworkTemplate template, int flags) {
+        SubscriptionPlan plan = null;
+        if ((flags & NetworkStatsManager.FLAG_AUGMENT_WITH_SUBSCRIPTION_PLAN) != 0
+                && mSettings.getAugmentEnabled()) {
+            if (LOGD) Log.d(TAG, "Resolving plan for " + template);
+            final long token = Binder.clearCallingIdentity();
+            try {
+                plan = mContext.getSystemService(NetworkPolicyManager.class)
+                        .getSubscriptionPlan(template);
+            } finally {
+                Binder.restoreCallingIdentity(token);
+            }
+            if (LOGD) Log.d(TAG, "Resolved to plan " + plan);
+        }
+        return plan;
+    }
+
+    /**
+     * Return network summary, splicing between DEV and XT stats when
+     * appropriate.
+     */
+    private NetworkStats internalGetSummaryForNetwork(NetworkTemplate template, int flags,
+            long start, long end, @NetworkStatsAccess.Level int accessLevel, int callingUid) {
+        // We've been using pure XT stats long enough that we no longer need to
+        // splice DEV and XT together.
+        final NetworkStatsHistory history = internalGetHistoryForNetwork(template, flags, FIELD_ALL,
+                accessLevel, callingUid, start, end);
+
+        final long now = System.currentTimeMillis();
+        final NetworkStatsHistory.Entry entry = history.getValues(start, end, now, null);
+
+        final NetworkStats stats = new NetworkStats(end - start, 1);
+        stats.insertEntry(new NetworkStats.Entry(IFACE_ALL, UID_ALL, SET_ALL, TAG_NONE,
+                METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, entry.rxBytes, entry.rxPackets,
+                entry.txBytes, entry.txPackets, entry.operations));
+        return stats;
+    }
+
+    /**
+     * Return network history, splicing between DEV and XT stats when
+     * appropriate.
+     */
+    private NetworkStatsHistory internalGetHistoryForNetwork(NetworkTemplate template,
+            int flags, int fields, @NetworkStatsAccess.Level int accessLevel, int callingUid,
+            long start, long end) {
+        // We've been using pure XT stats long enough that we no longer need to
+        // splice DEV and XT together.
+        final SubscriptionPlan augmentPlan = resolveSubscriptionPlan(template, flags);
+        synchronized (mStatsLock) {
+            return mXtStatsCached.getHistory(template, augmentPlan,
+                    UID_ALL, SET_ALL, TAG_NONE, fields, start, end, accessLevel, callingUid);
+        }
+    }
+
+    private long getNetworkTotalBytes(NetworkTemplate template, long start, long end) {
+        assertSystemReady();
+
+        return internalGetSummaryForNetwork(template,
+                NetworkStatsManager.FLAG_AUGMENT_WITH_SUBSCRIPTION_PLAN, start, end,
+                NetworkStatsAccess.Level.DEVICE, Binder.getCallingUid()).getTotalBytes();
+    }
+
+    private NetworkStats getNetworkUidBytes(NetworkTemplate template, long start, long end) {
+        assertSystemReady();
+
+        final NetworkStatsCollection uidComplete;
+        synchronized (mStatsLock) {
+            uidComplete = mUidRecorder.getOrLoadCompleteLocked();
+        }
+        return uidComplete.getSummary(template, start, end, NetworkStatsAccess.Level.DEVICE,
+                android.os.Process.SYSTEM_UID);
+    }
+
+    @Override
+    public NetworkStats getDataLayerSnapshotForUid(int uid) throws RemoteException {
+        if (Binder.getCallingUid() != uid) {
+            Log.w(TAG, "Snapshots only available for calling UID");
+            return new NetworkStats(SystemClock.elapsedRealtime(), 0);
+        }
+
+        // TODO: switch to data layer stats once kernel exports
+        // for now, read network layer stats and flatten across all ifaces.
+        // This function is used to query NeworkStats for calle's uid. The only caller method
+        // TrafficStats#getDataLayerSnapshotForUid alrady claim no special permission to query
+        // its own NetworkStats.
+        final long ident = Binder.clearCallingIdentity();
+        final NetworkStats networkLayer;
+        try {
+            networkLayer = readNetworkStatsUidDetail(uid, INTERFACES_ALL, TAG_ALL);
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+
+        // splice in operation counts
+        networkLayer.spliceOperationsFrom(mUidOperations);
+
+        final NetworkStats dataLayer = new NetworkStats(
+                networkLayer.getElapsedRealtime(), networkLayer.size());
+
+        NetworkStats.Entry entry = null;
+        for (int i = 0; i < networkLayer.size(); i++) {
+            entry = networkLayer.getValues(i, entry);
+            entry.iface = IFACE_ALL;
+            dataLayer.combineValues(entry);
+        }
+
+        return dataLayer;
+    }
+
+    @Override
+    public NetworkStats getUidStatsForTransport(int transport) {
+        PermissionUtils.enforceNetworkStackPermission(mContext);
+        try {
+            final String[] relevantIfaces =
+                    transport == TRANSPORT_WIFI ? mWifiIfaces : mMobileIfaces;
+            // TODO(b/215633405) : mMobileIfaces and mWifiIfaces already contain the stacked
+            // interfaces, so this is not useful, remove it.
+            final String[] ifacesToQuery =
+                    mStatsFactory.augmentWithStackedInterfaces(relevantIfaces);
+            return getNetworkStatsUidDetail(ifacesToQuery);
+        } catch (RemoteException e) {
+            Log.wtf(TAG, "Error compiling UID stats", e);
+            return new NetworkStats(0L, 0);
+        }
+    }
+
+    @Override
+    public String[] getMobileIfaces() {
+        // TODO (b/192758557): Remove debug log.
+        if (CollectionUtils.contains(mMobileIfaces, null)) {
+            throw new NullPointerException(
+                    "null element in mMobileIfaces: " + Arrays.toString(mMobileIfaces));
+        }
+        return mMobileIfaces.clone();
+    }
+
+    @Override
+    public void incrementOperationCount(int uid, int tag, int operationCount) {
+        if (Binder.getCallingUid() != uid) {
+            mContext.enforceCallingOrSelfPermission(UPDATE_DEVICE_STATS, TAG);
+        }
+
+        if (operationCount < 0) {
+            throw new IllegalArgumentException("operation count can only be incremented");
+        }
+        if (tag == TAG_NONE) {
+            throw new IllegalArgumentException("operation count must have specific tag");
+        }
+
+        synchronized (mStatsLock) {
+            final int set = mActiveUidCounterSet.get(uid, SET_DEFAULT);
+            mUidOperations.combineValues(
+                    mActiveIface, uid, set, tag, 0L, 0L, 0L, 0L, operationCount);
+            mUidOperations.combineValues(
+                    mActiveIface, uid, set, TAG_NONE, 0L, 0L, 0L, 0L, operationCount);
+        }
+    }
+
+    private void setKernelCounterSet(int uid, int set) {
+        if (mUidCounterSetMap == null) {
+            Log.wtf(TAG, "Fail to set UidCounterSet: Null bpf map");
+            return;
+        }
+
+        if (set == SET_DEFAULT) {
+            try {
+                mUidCounterSetMap.deleteEntry(new U32(uid));
+            } catch (ErrnoException e) {
+                Log.w(TAG, "UidCounterSetMap.deleteEntry(" + uid + ") failed with errno: " + e);
+            }
+            return;
+        }
+
+        try {
+            mUidCounterSetMap.updateEntry(new U32(uid), new U8((short) set));
+        } catch (ErrnoException e) {
+            Log.w(TAG, "UidCounterSetMap.updateEntry(" + uid + ", " + set
+                    + ") failed with errno: " + e);
+        }
+    }
+
+    @VisibleForTesting
+    public void noteUidForeground(int uid, boolean uidForeground) {
+        PermissionUtils.enforceNetworkStackPermission(mContext);
+        synchronized (mStatsLock) {
+            final int set = uidForeground ? SET_FOREGROUND : SET_DEFAULT;
+            final int oldSet = mActiveUidCounterSet.get(uid, SET_DEFAULT);
+            if (oldSet != set) {
+                mActiveUidCounterSet.put(uid, set);
+                setKernelCounterSet(uid, set);
+            }
+        }
+    }
+
+    /**
+     * Notify {@code NetworkStatsService} about network status changed.
+     */
+    public void notifyNetworkStatus(
+            @NonNull Network[] defaultNetworks,
+            @NonNull NetworkStateSnapshot[] networkStates,
+            @Nullable String activeIface,
+            @NonNull UnderlyingNetworkInfo[] underlyingNetworkInfos) {
+        PermissionUtils.enforceNetworkStackPermission(mContext);
+
+        final long token = Binder.clearCallingIdentity();
+        try {
+            handleNotifyNetworkStatus(defaultNetworks, networkStates, activeIface);
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+
+        // Update the VPN underlying interfaces only after the poll is made and tun data has been
+        // migrated. Otherwise the migration would use the new interfaces instead of the ones that
+        // were current when the polled data was transferred.
+        mStatsFactory.updateUnderlyingNetworkInfos(underlyingNetworkInfos);
+    }
+
+    @Override
+    public void forceUpdate() {
+        PermissionUtils.enforceNetworkStackPermission(mContext);
+
+        final long token = Binder.clearCallingIdentity();
+        try {
+            performPoll(FLAG_PERSIST_ALL);
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    /** Advise persistence threshold; may be overridden internally. */
+    public void advisePersistThreshold(long thresholdBytes) {
+        PermissionUtils.enforceNetworkStackPermission(mContext);
+        // clamp threshold into safe range
+        mPersistThreshold = NetworkStatsUtils.constrain(thresholdBytes,
+                128 * KB_IN_BYTES, 2 * MB_IN_BYTES);
+        if (LOGV) {
+            Log.v(TAG, "advisePersistThreshold() given " + thresholdBytes + ", clamped to "
+                    + mPersistThreshold);
+        }
+
+        final long oldGlobalAlertBytes = mGlobalAlertBytes;
+
+        // update and persist if beyond new thresholds
+        final long currentTime = mClock.millis();
+        synchronized (mStatsLock) {
+            if (!mSystemReady) return;
+
+            updatePersistThresholdsLocked();
+
+            mDevRecorder.maybePersistLocked(currentTime);
+            mXtRecorder.maybePersistLocked(currentTime);
+            mUidRecorder.maybePersistLocked(currentTime);
+            mUidTagRecorder.maybePersistLocked(currentTime);
+        }
+
+        if (oldGlobalAlertBytes != mGlobalAlertBytes) {
+            registerGlobalAlert();
+        }
+    }
+
+    @Override
+    public DataUsageRequest registerUsageCallback(@NonNull String callingPackage,
+                @NonNull DataUsageRequest request, @NonNull IUsageCallback callback) {
+        Objects.requireNonNull(callingPackage, "calling package is null");
+        Objects.requireNonNull(request, "DataUsageRequest is null");
+        Objects.requireNonNull(request.template, "NetworkTemplate is null");
+        Objects.requireNonNull(callback, "callback is null");
+
+        int callingUid = Binder.getCallingUid();
+        @NetworkStatsAccess.Level int accessLevel = checkAccessLevel(callingPackage);
+        DataUsageRequest normalizedRequest;
+        final long token = Binder.clearCallingIdentity();
+        try {
+            normalizedRequest = mStatsObservers.register(mContext,
+                    request, callback, callingUid, accessLevel);
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+
+        // Create baseline stats
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_PERFORM_POLL));
+
+        return normalizedRequest;
+   }
+
+    @Override
+    public void unregisterUsageRequest(DataUsageRequest request) {
+        Objects.requireNonNull(request, "DataUsageRequest is null");
+
+        int callingUid = Binder.getCallingUid();
+        final long token = Binder.clearCallingIdentity();
+        try {
+            mStatsObservers.unregister(request, callingUid);
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    @Override
+    public long getUidStats(int uid, int type) {
+        final int callingUid = Binder.getCallingUid();
+        if (callingUid != android.os.Process.SYSTEM_UID && callingUid != uid) {
+            return UNSUPPORTED;
+        }
+        return nativeGetUidStat(uid, type);
+    }
+
+    @Override
+    public long getIfaceStats(@NonNull String iface, int type) {
+        Objects.requireNonNull(iface);
+        long nativeIfaceStats = nativeGetIfaceStat(iface, type);
+        if (nativeIfaceStats == -1) {
+            return nativeIfaceStats;
+        } else {
+            // When tethering offload is in use, nativeIfaceStats does not contain usage from
+            // offload, add it back here. Note that the included statistics might be stale
+            // since polling newest stats from hardware might impact system health and not
+            // suitable for TrafficStats API use cases.
+            return nativeIfaceStats + getProviderIfaceStats(iface, type);
+        }
+    }
+
+    @Override
+    public long getTotalStats(int type) {
+        long nativeTotalStats = nativeGetTotalStat(type);
+        if (nativeTotalStats == -1) {
+            return nativeTotalStats;
+        } else {
+            // Refer to comment in getIfaceStats
+            return nativeTotalStats + getProviderIfaceStats(IFACE_ALL, type);
+        }
+    }
+
+    private long getProviderIfaceStats(@Nullable String iface, int type) {
+        final NetworkStats providerSnapshot = getNetworkStatsFromProviders(STATS_PER_IFACE);
+        final HashSet<String> limitIfaces;
+        if (iface == IFACE_ALL) {
+            limitIfaces = null;
+        } else {
+            limitIfaces = new HashSet<>();
+            limitIfaces.add(iface);
+        }
+        final NetworkStats.Entry entry = providerSnapshot.getTotal(null, limitIfaces);
+        switch (type) {
+            case TrafficStats.TYPE_RX_BYTES:
+                return entry.rxBytes;
+            case TrafficStats.TYPE_RX_PACKETS:
+                return entry.rxPackets;
+            case TrafficStats.TYPE_TX_BYTES:
+                return entry.txBytes;
+            case TrafficStats.TYPE_TX_PACKETS:
+                return entry.txPackets;
+            default:
+                return 0;
+        }
+    }
+
+    /**
+     * Update {@link NetworkStatsRecorder} and {@link #mGlobalAlertBytes} to
+     * reflect current {@link #mPersistThreshold} value. Always defers to
+     * {@link Global} values when defined.
+     */
+    @GuardedBy("mStatsLock")
+    private void updatePersistThresholdsLocked() {
+        mDevRecorder.setPersistThreshold(mSettings.getDevPersistBytes(mPersistThreshold));
+        mXtRecorder.setPersistThreshold(mSettings.getXtPersistBytes(mPersistThreshold));
+        mUidRecorder.setPersistThreshold(mSettings.getUidPersistBytes(mPersistThreshold));
+        mUidTagRecorder.setPersistThreshold(mSettings.getUidTagPersistBytes(mPersistThreshold));
+        mGlobalAlertBytes = mSettings.getGlobalAlertBytes(mPersistThreshold);
+    }
+
+    /**
+     * Listener that watches for {@link TetheringManager} to claim interface pairs.
+     */
+    private final TetheringManager.TetheringEventCallback mTetherListener =
+            new TetheringManager.TetheringEventCallback() {
+                @Override
+                public void onUpstreamChanged(@Nullable Network network) {
+                    performPoll(FLAG_PERSIST_NETWORK);
+                }
+            };
+
+    private BroadcastReceiver mPollReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // on background handler thread, and verified UPDATE_DEVICE_STATS
+            // permission above.
+            performPoll(FLAG_PERSIST_ALL);
+
+            // verify that we're watching global alert
+            registerGlobalAlert();
+        }
+    };
+
+    private BroadcastReceiver mRemovedReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // on background handler thread, and UID_REMOVED is protected
+            // broadcast.
+
+            final int uid = intent.getIntExtra(EXTRA_UID, -1);
+            if (uid == -1) return;
+
+            synchronized (mStatsLock) {
+                mWakeLock.acquire();
+                try {
+                    removeUidsLocked(uid);
+                } finally {
+                    mWakeLock.release();
+                }
+            }
+        }
+    };
+
+    private BroadcastReceiver mUserReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // On background handler thread, and USER_REMOVED is protected
+            // broadcast.
+
+            final UserHandle userHandle = intent.getParcelableExtra(Intent.EXTRA_USER);
+            if (userHandle == null) return;
+
+            synchronized (mStatsLock) {
+                mWakeLock.acquire();
+                try {
+                    removeUserLocked(userHandle);
+                } finally {
+                    mWakeLock.release();
+                }
+            }
+        }
+    };
+
+    private BroadcastReceiver mShutdownReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // SHUTDOWN is protected broadcast.
+            synchronized (mStatsLock) {
+                shutdownLocked();
+            }
+        }
+    };
+
+    /**
+     * Handle collapsed RAT type changed event.
+     */
+    @VisibleForTesting
+    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());
+    }
+
+    private void handleNotifyNetworkStatus(
+            Network[] defaultNetworks,
+            NetworkStateSnapshot[] snapshots,
+            String activeIface) {
+        synchronized (mStatsLock) {
+            mWakeLock.acquire();
+            try {
+                mActiveIface = activeIface;
+                handleNotifyNetworkStatusLocked(defaultNetworks, snapshots);
+            } finally {
+                mWakeLock.release();
+            }
+        }
+    }
+
+    /**
+     * Inspect all current {@link NetworkStateSnapshot}s to derive mapping from {@code iface} to
+     * {@link NetworkStatsHistory}. When multiple networks are active on a single {@code iface},
+     * they are combined under a single {@link NetworkIdentitySet}.
+     */
+    @GuardedBy("mStatsLock")
+    private void handleNotifyNetworkStatusLocked(@NonNull Network[] defaultNetworks,
+            @NonNull NetworkStateSnapshot[] snapshots) {
+        if (!mSystemReady) return;
+        if (LOGV) Log.v(TAG, "handleNotifyNetworkStatusLocked()");
+
+        // take one last stats snapshot before updating iface mapping. this
+        // isn't perfect, since the kernel may already be counting traffic from
+        // the updated network.
+
+        // poll, but only persist network stats to keep codepath fast. UID stats
+        // will be persisted during next alarm poll event.
+        performPollLocked(FLAG_PERSIST_NETWORK);
+
+        // Rebuild active interfaces based on connected networks
+        mActiveIfaces.clear();
+        mActiveUidIfaces.clear();
+        // Update the list of default networks.
+        mDefaultNetworks = defaultNetworks;
+
+        mLastNetworkStateSnapshots = snapshots;
+
+        final boolean combineSubtypeEnabled = mSettings.getCombineSubtypeEnabled();
+        final ArraySet<String> mobileIfaces = new ArraySet<>();
+        final ArraySet<String> wifiIfaces = new ArraySet<>();
+        for (NetworkStateSnapshot snapshot : snapshots) {
+            final int displayTransport =
+                    getDisplayTransport(snapshot.getNetworkCapabilities().getTransportTypes());
+            final boolean isMobile = (NetworkCapabilities.TRANSPORT_CELLULAR == displayTransport);
+            final boolean isWifi = (NetworkCapabilities.TRANSPORT_WIFI == displayTransport);
+            final boolean isDefault = CollectionUtils.contains(
+                    mDefaultNetworks, snapshot.getNetwork());
+            final int ratType = combineSubtypeEnabled ? NetworkTemplate.NETWORK_TYPE_ALL
+                    : getRatTypeForStateSnapshot(snapshot);
+            final NetworkIdentity ident = NetworkIdentity.buildNetworkIdentity(mContext, snapshot,
+                    isDefault, ratType);
+
+            // Traffic occurring on the base interface is always counted for
+            // both total usage and UID details.
+            final String baseIface = snapshot.getLinkProperties().getInterfaceName();
+            if (baseIface != null) {
+                findOrCreateNetworkIdentitySet(mActiveIfaces, baseIface).add(ident);
+                findOrCreateNetworkIdentitySet(mActiveUidIfaces, baseIface).add(ident);
+
+                // Build a separate virtual interface for VT (Video Telephony) data usage.
+                // Only do this when IMS is not metered, but VT is metered.
+                // If IMS is metered, then the IMS network usage has already included VT usage.
+                // VT is considered always metered in framework's layer. If VT is not metered
+                // per carrier's policy, modem will report 0 usage for VT calls.
+                if (snapshot.getNetworkCapabilities().hasCapability(
+                        NetworkCapabilities.NET_CAPABILITY_IMS) && !ident.isMetered()) {
+
+                    // Copy the identify from IMS one but mark it as metered.
+                    NetworkIdentity vtIdent = new NetworkIdentity.Builder()
+                            .setType(ident.getType())
+                            .setRatType(ident.getRatType())
+                            .setSubscriberId(ident.getSubscriberId())
+                            .setWifiNetworkKey(ident.getWifiNetworkKey())
+                            .setRoaming(ident.isRoaming()).setMetered(true)
+                            .setDefaultNetwork(true)
+                            .setOemManaged(ident.getOemManaged())
+                            .setSubId(ident.getSubId()).build();
+                    final String ifaceVt = IFACE_VT + getSubIdForMobile(snapshot);
+                    findOrCreateNetworkIdentitySet(mActiveIfaces, ifaceVt).add(vtIdent);
+                    findOrCreateNetworkIdentitySet(mActiveUidIfaces, ifaceVt).add(vtIdent);
+                }
+
+                if (isMobile) {
+                    mobileIfaces.add(baseIface);
+                }
+                if (isWifi) {
+                    wifiIfaces.add(baseIface);
+                }
+            }
+
+            // Traffic occurring on stacked interfaces is usually clatd.
+            //
+            // UID stats are always counted on the stacked interface and never on the base
+            // interface, because the packets on the base interface do not actually match
+            // application sockets (they're not IPv4) and thus the app uid is not known.
+            // For receive this is obvious: packets must be translated from IPv6 to IPv4
+            // before the application socket can be found.
+            // For transmit: either they go through the clat daemon which by virtue of going
+            // through userspace strips the original socket association during the IPv4 to
+            // IPv6 translation process, or they are offloaded by eBPF, which doesn't:
+            // However, on an ebpf device the accounting is done in cgroup ebpf hooks,
+            // which don't trigger again post ebpf translation.
+            // (as such stats accounted to the clat uid are ignored)
+            //
+            // Interface stats are more complicated.
+            //
+            // eBPF offloaded 464xlat'ed packets never hit base interface ip6tables, and thus
+            // *all* statistics are collected by iptables on the stacked v4-* interface.
+            //
+            // Additionally for ingress all packets bound for the clat IPv6 address are dropped
+            // in ip6tables raw prerouting and thus even non-offloaded packets are only
+            // accounted for on the stacked interface.
+            //
+            // For egress, packets subject to eBPF offload never appear on the base interface
+            // and only appear on the stacked interface. Thus to ensure packets increment
+            // interface stats, we must collate data from stacked interfaces. For xt_qtaguid
+            // (or non eBPF offloaded) TX they would appear on both, however egress interface
+            // accounting is explicitly bypassed for traffic from the clat uid.
+            //
+            // TODO: This code might be combined to above code.
+            for (String iface : snapshot.getLinkProperties().getAllInterfaceNames()) {
+                // baseIface has been handled, so ignore it.
+                if (TextUtils.equals(baseIface, iface)) continue;
+                if (iface != null) {
+                    findOrCreateNetworkIdentitySet(mActiveIfaces, iface).add(ident);
+                    findOrCreateNetworkIdentitySet(mActiveUidIfaces, iface).add(ident);
+                    if (isMobile) {
+                        mobileIfaces.add(iface);
+                    }
+                    if (isWifi) {
+                        wifiIfaces.add(iface);
+                    }
+
+                    mStatsFactory.noteStackedIface(iface, baseIface);
+                }
+            }
+        }
+
+        mMobileIfaces = mobileIfaces.toArray(new String[0]);
+        mWifiIfaces = wifiIfaces.toArray(new String[0]);
+        // TODO (b/192758557): Remove debug log.
+        if (CollectionUtils.contains(mMobileIfaces, null)) {
+            throw new NullPointerException(
+                    "null element in mMobileIfaces: " + Arrays.toString(mMobileIfaces));
+        }
+        if (CollectionUtils.contains(mWifiIfaces, null)) {
+            throw new NullPointerException(
+                    "null element in mWifiIfaces: " + Arrays.toString(mWifiIfaces));
+        }
+    }
+
+    private static int getSubIdForMobile(@NonNull NetworkStateSnapshot state) {
+        if (!state.getNetworkCapabilities().hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+            throw new IllegalArgumentException("Mobile state need capability TRANSPORT_CELLULAR");
+        }
+
+        final NetworkSpecifier spec = state.getNetworkCapabilities().getNetworkSpecifier();
+        if (spec instanceof TelephonyNetworkSpecifier) {
+             return ((TelephonyNetworkSpecifier) spec).getSubscriptionId();
+        } else {
+            Log.wtf(TAG, "getSubIdForState invalid NetworkSpecifier");
+            return INVALID_SUBSCRIPTION_ID;
+        }
+    }
+
+    /**
+     * For networks with {@code TRANSPORT_CELLULAR}, get ratType that was obtained through
+     * {@link PhoneStateListener}. Otherwise, return 0 given that other networks with different
+     * transport types do not actually fill this value.
+     */
+    private int getRatTypeForStateSnapshot(@NonNull NetworkStateSnapshot state) {
+        if (!state.getNetworkCapabilities().hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+            return 0;
+        }
+
+        return mNetworkStatsSubscriptionsMonitor.getRatTypeForSubscriberId(state.getSubscriberId());
+    }
+
+    private static <K> NetworkIdentitySet findOrCreateNetworkIdentitySet(
+            ArrayMap<K, NetworkIdentitySet> map, K key) {
+        NetworkIdentitySet ident = map.get(key);
+        if (ident == null) {
+            ident = new NetworkIdentitySet();
+            map.put(key, ident);
+        }
+        return ident;
+    }
+
+    @GuardedBy("mStatsLock")
+    private void recordSnapshotLocked(long currentTime) throws RemoteException {
+        // snapshot and record current counters; read UID stats first to
+        // avoid over counting dev stats.
+        Trace.traceBegin(TRACE_TAG_NETWORK, "snapshotUid");
+        final NetworkStats uidSnapshot = getNetworkStatsUidDetail(INTERFACES_ALL);
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+        Trace.traceBegin(TRACE_TAG_NETWORK, "snapshotXt");
+        final NetworkStats xtSnapshot = readNetworkStatsSummaryXt();
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+        Trace.traceBegin(TRACE_TAG_NETWORK, "snapshotDev");
+        final NetworkStats devSnapshot = readNetworkStatsSummaryDev();
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+
+        // Snapshot for dev/xt stats from all custom stats providers. Counts per-interface data
+        // from stats providers that isn't already counted by dev and XT stats.
+        Trace.traceBegin(TRACE_TAG_NETWORK, "snapshotStatsProvider");
+        final NetworkStats providersnapshot = getNetworkStatsFromProviders(STATS_PER_IFACE);
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+        xtSnapshot.combineAllValues(providersnapshot);
+        devSnapshot.combineAllValues(providersnapshot);
+
+        // For xt/dev, we pass a null VPN array because usage is aggregated by UID, so VPN traffic
+        // can't be reattributed to responsible apps.
+        Trace.traceBegin(TRACE_TAG_NETWORK, "recordDev");
+        mDevRecorder.recordSnapshotLocked(devSnapshot, mActiveIfaces, currentTime);
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+        Trace.traceBegin(TRACE_TAG_NETWORK, "recordXt");
+        mXtRecorder.recordSnapshotLocked(xtSnapshot, mActiveIfaces, currentTime);
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+
+        // For per-UID stats, pass the VPN info so VPN traffic is reattributed to responsible apps.
+        Trace.traceBegin(TRACE_TAG_NETWORK, "recordUid");
+        mUidRecorder.recordSnapshotLocked(uidSnapshot, mActiveUidIfaces, currentTime);
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+        Trace.traceBegin(TRACE_TAG_NETWORK, "recordUidTag");
+        mUidTagRecorder.recordSnapshotLocked(uidSnapshot, mActiveUidIfaces, currentTime);
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+
+        // We need to make copies of member fields that are sent to the observer to avoid
+        // a race condition between the service handler thread and the observer's
+        mStatsObservers.updateStats(xtSnapshot, uidSnapshot, new ArrayMap<>(mActiveIfaces),
+                new ArrayMap<>(mActiveUidIfaces), currentTime);
+    }
+
+    /**
+     * Bootstrap initial stats snapshot, usually during {@link #systemReady()}
+     * so we have baseline values without double-counting.
+     */
+    @GuardedBy("mStatsLock")
+    private void bootstrapStatsLocked() {
+        final long currentTime = mClock.millis();
+
+        try {
+            recordSnapshotLocked(currentTime);
+        } catch (IllegalStateException e) {
+            Log.w(TAG, "problem reading network stats: " + e);
+        } catch (RemoteException e) {
+            // ignored; service lives in system_server
+        }
+    }
+
+    private void performPoll(int flags) {
+        synchronized (mStatsLock) {
+            mWakeLock.acquire();
+
+            try {
+                performPollLocked(flags);
+            } finally {
+                mWakeLock.release();
+            }
+        }
+    }
+
+    /**
+     * Periodic poll operation, reading current statistics and recording into
+     * {@link NetworkStatsHistory}.
+     */
+    @GuardedBy("mStatsLock")
+    private void performPollLocked(int flags) {
+        if (!mSystemReady) return;
+        if (LOGV) Log.v(TAG, "performPollLocked(flags=0x" + Integer.toHexString(flags) + ")");
+        Trace.traceBegin(TRACE_TAG_NETWORK, "performPollLocked");
+
+        final boolean persistNetwork = (flags & FLAG_PERSIST_NETWORK) != 0;
+        final boolean persistUid = (flags & FLAG_PERSIST_UID) != 0;
+        final boolean persistForce = (flags & FLAG_PERSIST_FORCE) != 0;
+
+        performPollFromProvidersLocked();
+
+        // TODO: consider marking "untrusted" times in historical stats
+        final long currentTime = mClock.millis();
+
+        try {
+            recordSnapshotLocked(currentTime);
+        } catch (IllegalStateException e) {
+            Log.wtf(TAG, "problem reading network stats", e);
+            return;
+        } catch (RemoteException e) {
+            // ignored; service lives in system_server
+            return;
+        }
+
+        // persist any pending data depending on requested flags
+        Trace.traceBegin(TRACE_TAG_NETWORK, "[persisting]");
+        if (persistForce) {
+            mDevRecorder.forcePersistLocked(currentTime);
+            mXtRecorder.forcePersistLocked(currentTime);
+            mUidRecorder.forcePersistLocked(currentTime);
+            mUidTagRecorder.forcePersistLocked(currentTime);
+        } else {
+            if (persistNetwork) {
+                mDevRecorder.maybePersistLocked(currentTime);
+                mXtRecorder.maybePersistLocked(currentTime);
+            }
+            if (persistUid) {
+                mUidRecorder.maybePersistLocked(currentTime);
+                mUidTagRecorder.maybePersistLocked(currentTime);
+            }
+        }
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+
+        if (mSettings.getSampleEnabled()) {
+            // sample stats after each full poll
+            performSampleLocked();
+        }
+
+        // finally, dispatch updated event to any listeners
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_BROADCAST_NETWORK_STATS_UPDATED));
+
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+    }
+
+    @GuardedBy("mStatsLock")
+    private void performPollFromProvidersLocked() {
+        // Request asynchronous stats update from all providers for next poll. And wait a bit of
+        // time to allow providers report-in given that normally binder call should be fast. Note
+        // that size of list might be changed because addition/removing at the same time. For
+        // addition, the stats of the missed provider can only be collected in next poll;
+        // for removal, wait might take up to MAX_STATS_PROVIDER_POLL_WAIT_TIME_MS
+        // once that happened.
+        // TODO: request with a valid token.
+        Trace.traceBegin(TRACE_TAG_NETWORK, "provider.requestStatsUpdate");
+        final int registeredCallbackCount = mStatsProviderCbList.size();
+        mStatsProviderSem.drainPermits();
+        invokeForAllStatsProviderCallbacks(
+                (cb) -> cb.mProvider.onRequestStatsUpdate(0 /* unused */));
+        try {
+            mStatsProviderSem.tryAcquire(registeredCallbackCount,
+                    MAX_STATS_PROVIDER_POLL_WAIT_TIME_MS, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            // Strictly speaking it's possible a provider happened to deliver between the timeout
+            // and the log, and that doesn't matter too much as this is just a debug log.
+            Log.d(TAG, "requestStatsUpdate - providers responded "
+                    + mStatsProviderSem.availablePermits()
+                    + "/" + registeredCallbackCount + " : " + e);
+        }
+        Trace.traceEnd(TRACE_TAG_NETWORK);
+    }
+
+    /**
+     * Sample recent statistics summary into {@link EventLog}.
+     */
+    @GuardedBy("mStatsLock")
+    private void performSampleLocked() {
+        // TODO: migrate trustedtime fixes to separate binary log events
+        final long currentTime = mClock.millis();
+
+        NetworkTemplate template;
+        NetworkStats.Entry devTotal;
+        NetworkStats.Entry xtTotal;
+        NetworkStats.Entry uidTotal;
+
+        // collect mobile sample
+        template = buildTemplateMobileWildcard();
+        devTotal = mDevRecorder.getTotalSinceBootLocked(template);
+        xtTotal = mXtRecorder.getTotalSinceBootLocked(template);
+        uidTotal = mUidRecorder.getTotalSinceBootLocked(template);
+
+        EventLog.writeEvent(LOG_TAG_NETSTATS_MOBILE_SAMPLE,
+                devTotal.rxBytes, devTotal.rxPackets, devTotal.txBytes, devTotal.txPackets,
+                xtTotal.rxBytes, xtTotal.rxPackets, xtTotal.txBytes, xtTotal.txPackets,
+                uidTotal.rxBytes, uidTotal.rxPackets, uidTotal.txBytes, uidTotal.txPackets,
+                currentTime);
+
+        // collect wifi sample
+        template = buildTemplateWifiWildcard();
+        devTotal = mDevRecorder.getTotalSinceBootLocked(template);
+        xtTotal = mXtRecorder.getTotalSinceBootLocked(template);
+        uidTotal = mUidRecorder.getTotalSinceBootLocked(template);
+
+        EventLog.writeEvent(LOG_TAG_NETSTATS_WIFI_SAMPLE,
+                devTotal.rxBytes, devTotal.rxPackets, devTotal.txBytes, devTotal.txPackets,
+                xtTotal.rxBytes, xtTotal.rxPackets, xtTotal.txBytes, xtTotal.txPackets,
+                uidTotal.rxBytes, uidTotal.rxPackets, uidTotal.txBytes, uidTotal.txPackets,
+                currentTime);
+    }
+
+    // deleteKernelTagData can ignore ENOENT; otherwise we should log an error
+    private void logErrorIfNotErrNoent(final ErrnoException e, final String msg) {
+        if (e.errno != ENOENT) Log.e(TAG, msg, e);
+    }
+
+    private <K extends StatsMapKey, V extends StatsMapValue> void deleteStatsMapTagData(
+            IBpfMap<K, V> statsMap, int uid) {
+        try {
+            statsMap.forEach((key, value) -> {
+                if (key.uid == uid) {
+                    try {
+                        statsMap.deleteEntry(key);
+                    } catch (ErrnoException e) {
+                        logErrorIfNotErrNoent(e, "Failed to delete data(uid = " + key.uid + ")");
+                    }
+                }
+            });
+        } catch (ErrnoException e) {
+            Log.e(TAG, "FAILED to delete tag data from stats map", e);
+        }
+    }
+
+    /**
+     * Deletes uid tag data from CookieTagMap, StatsMapA, StatsMapB, and UidStatsMap
+     * @param uid
+     */
+    private void deleteKernelTagData(int uid) {
+        try {
+            mCookieTagMap.forEach((key, value) -> {
+                // If SkDestroyListener deletes the socket tag while this code is running,
+                // forEach will either restart iteration from the beginning or return null,
+                // depending on when the deletion happens.
+                // If it returns null, continue iteration to delete the data and in fact it would
+                // just iterate from first key because BpfMap#getNextKey would return first key
+                // if the current key is not exist.
+                if (value != null && value.uid == uid) {
+                    try {
+                        mCookieTagMap.deleteEntry(key);
+                    } catch (ErrnoException e) {
+                        logErrorIfNotErrNoent(e, "Failed to delete data(cookie = " + key + ")");
+                    }
+                }
+            });
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to delete tag data from cookie tag map", e);
+        }
+
+        deleteStatsMapTagData(mStatsMapA, uid);
+        deleteStatsMapTagData(mStatsMapB, uid);
+
+        try {
+            mUidCounterSetMap.deleteEntry(new U32(uid));
+        } catch (ErrnoException e) {
+            logErrorIfNotErrNoent(e, "Failed to delete tag data from uid counter set map");
+        }
+
+        try {
+            mAppUidStatsMap.deleteEntry(new UidStatsMapKey(uid));
+        } catch (ErrnoException e) {
+            logErrorIfNotErrNoent(e, "Failed to delete tag data from app uid stats map");
+        }
+    }
+
+    /**
+     * Clean up {@link #mUidRecorder} after UID is removed.
+     */
+    @GuardedBy("mStatsLock")
+    private void removeUidsLocked(int... uids) {
+        if (LOGV) Log.v(TAG, "removeUidsLocked() for UIDs " + Arrays.toString(uids));
+
+        // Perform one last poll before removing
+        performPollLocked(FLAG_PERSIST_ALL);
+
+        mUidRecorder.removeUidsLocked(uids);
+        mUidTagRecorder.removeUidsLocked(uids);
+
+        // Clear kernel stats associated with UID
+        for (int uid : uids) {
+            deleteKernelTagData(uid);
+        }
+    }
+
+    /**
+     * Clean up {@link #mUidRecorder} after user is removed.
+     */
+    @GuardedBy("mStatsLock")
+    private void removeUserLocked(@NonNull UserHandle userHandle) {
+        if (LOGV) Log.v(TAG, "removeUserLocked() for UserHandle=" + userHandle);
+
+        // Build list of UIDs that we should clean up
+        final ArrayList<Integer> uids = new ArrayList<>();
+        final List<ApplicationInfo> apps = mContext.getPackageManager().getInstalledApplications(
+                PackageManager.MATCH_ANY_USER
+                | PackageManager.MATCH_DISABLED_COMPONENTS);
+        for (ApplicationInfo app : apps) {
+            final int uid = userHandle.getUid(app.uid);
+            uids.add(uid);
+        }
+
+        removeUidsLocked(CollectionUtils.toIntArray(uids));
+    }
+
+    /**
+     * Set the warning and limit to all registered custom network stats providers.
+     * Note that invocation of any interface will be sent to all providers.
+     */
+    public void setStatsProviderWarningAndLimitAsync(
+            @NonNull String iface, long warning, long limit) {
+        PermissionUtils.enforceNetworkStackPermission(mContext);
+        if (LOGV) {
+            Log.v(TAG, "setStatsProviderWarningAndLimitAsync("
+                    + iface + "," + warning + "," + limit + ")");
+        }
+        invokeForAllStatsProviderCallbacks((cb) -> cb.mProvider.onSetWarningAndLimit(iface,
+                warning, limit));
+    }
+
+    @Override
+    protected void dump(FileDescriptor fd, PrintWriter rawWriter, String[] args) {
+        if (!PermissionUtils.checkDumpPermission(mContext, TAG, rawWriter)) return;
+
+        long duration = DateUtils.DAY_IN_MILLIS;
+        final HashSet<String> argSet = new HashSet<String>();
+        for (String arg : args) {
+            argSet.add(arg);
+
+            if (arg.startsWith("--duration=")) {
+                try {
+                    duration = Long.parseLong(arg.substring(11));
+                } catch (NumberFormatException ignored) {
+                }
+            }
+        }
+
+        // usage: dumpsys netstats --full --uid --tag --poll --checkin
+        final boolean poll = argSet.contains("--poll") || argSet.contains("poll");
+        final boolean checkin = argSet.contains("--checkin");
+        final boolean fullHistory = argSet.contains("--full") || argSet.contains("full");
+        final boolean includeUid = argSet.contains("--uid") || argSet.contains("detail");
+        final boolean includeTag = argSet.contains("--tag") || argSet.contains("detail");
+
+        final IndentingPrintWriter pw = new IndentingPrintWriter(rawWriter, "  ");
+
+        synchronized (mStatsLock) {
+            if (args.length > 0 && "--proto".equals(args[0])) {
+                // In this case ignore all other arguments.
+                dumpProtoLocked(fd);
+                return;
+            }
+
+            if (poll) {
+                performPollLocked(FLAG_PERSIST_ALL | FLAG_PERSIST_FORCE);
+                pw.println("Forced poll");
+                return;
+            }
+
+            if (checkin) {
+                final long end = System.currentTimeMillis();
+                final long start = end - duration;
+
+                pw.print("v1,");
+                pw.print(start / SECOND_IN_MILLIS); pw.print(',');
+                pw.print(end / SECOND_IN_MILLIS); pw.println();
+
+                pw.println("xt");
+                mXtRecorder.dumpCheckin(rawWriter, start, end);
+
+                if (includeUid) {
+                    pw.println("uid");
+                    mUidRecorder.dumpCheckin(rawWriter, start, end);
+                }
+                if (includeTag) {
+                    pw.println("tag");
+                    mUidTagRecorder.dumpCheckin(rawWriter, start, end);
+                }
+                return;
+            }
+
+            pw.println("Configs:");
+            pw.increaseIndent();
+            pw.print(NETSTATS_COMBINE_SUBTYPE_ENABLED, mSettings.getCombineSubtypeEnabled());
+            pw.println();
+            pw.decreaseIndent();
+
+            pw.println("Active interfaces:");
+            pw.increaseIndent();
+            for (int i = 0; i < mActiveIfaces.size(); i++) {
+                pw.print("iface", mActiveIfaces.keyAt(i));
+                pw.print("ident", mActiveIfaces.valueAt(i));
+                pw.println();
+            }
+            pw.decreaseIndent();
+
+            pw.println("Active UID interfaces:");
+            pw.increaseIndent();
+            for (int i = 0; i < mActiveUidIfaces.size(); i++) {
+                pw.print("iface", mActiveUidIfaces.keyAt(i));
+                pw.print("ident", mActiveUidIfaces.valueAt(i));
+                pw.println();
+            }
+            pw.decreaseIndent();
+
+            // Get the top openSession callers
+            final SparseIntArray calls;
+            synchronized (mOpenSessionCallsPerUid) {
+                calls = mOpenSessionCallsPerUid.clone();
+            }
+
+            final int N = calls.size();
+            final long[] values = new long[N];
+            for (int j = 0; j < N; j++) {
+                values[j] = ((long) calls.valueAt(j) << 32) | calls.keyAt(j);
+            }
+            Arrays.sort(values);
+
+            pw.println("Top openSession callers (uid=count):");
+            pw.increaseIndent();
+            final int end = Math.max(0, N - DUMP_STATS_SESSION_COUNT);
+            for (int j = N - 1; j >= end; j--) {
+                final int uid = (int) (values[j] & 0xffffffff);
+                final int count = (int) (values[j] >> 32);
+                pw.print(uid); pw.print("="); pw.println(count);
+            }
+            pw.decreaseIndent();
+            pw.println();
+
+            pw.println("Stats Providers:");
+            pw.increaseIndent();
+            invokeForAllStatsProviderCallbacks((cb) -> {
+                pw.println(cb.mTag + " Xt:");
+                pw.increaseIndent();
+                pw.print(cb.getCachedStats(STATS_PER_IFACE).toString());
+                pw.decreaseIndent();
+                if (includeUid) {
+                    pw.println(cb.mTag + " Uid:");
+                    pw.increaseIndent();
+                    pw.print(cb.getCachedStats(STATS_PER_UID).toString());
+                    pw.decreaseIndent();
+                }
+            });
+            pw.decreaseIndent();
+
+            pw.println("Dev stats:");
+            pw.increaseIndent();
+            mDevRecorder.dumpLocked(pw, fullHistory);
+            pw.decreaseIndent();
+
+            pw.println("Xt stats:");
+            pw.increaseIndent();
+            mXtRecorder.dumpLocked(pw, fullHistory);
+            pw.decreaseIndent();
+
+            if (includeUid) {
+                pw.println("UID stats:");
+                pw.increaseIndent();
+                mUidRecorder.dumpLocked(pw, fullHistory);
+                pw.decreaseIndent();
+            }
+
+            if (includeTag) {
+                pw.println("UID tag stats:");
+                pw.increaseIndent();
+                mUidTagRecorder.dumpLocked(pw, fullHistory);
+                pw.decreaseIndent();
+            }
+        }
+    }
+
+    @GuardedBy("mStatsLock")
+    private void dumpProtoLocked(FileDescriptor fd) {
+        final ProtoOutputStream proto = new ProtoOutputStream(new FileOutputStream(fd));
+
+        // TODO Right now it writes all history.  Should it limit to the "since-boot" log?
+
+        dumpInterfaces(proto, NetworkStatsServiceDumpProto.ACTIVE_INTERFACES,
+                mActiveIfaces);
+        dumpInterfaces(proto, NetworkStatsServiceDumpProto.ACTIVE_UID_INTERFACES,
+                mActiveUidIfaces);
+        mDevRecorder.dumpDebugLocked(proto, NetworkStatsServiceDumpProto.DEV_STATS);
+        mXtRecorder.dumpDebugLocked(proto, NetworkStatsServiceDumpProto.XT_STATS);
+        mUidRecorder.dumpDebugLocked(proto, NetworkStatsServiceDumpProto.UID_STATS);
+        mUidTagRecorder.dumpDebugLocked(proto,
+                NetworkStatsServiceDumpProto.UID_TAG_STATS);
+
+        proto.flush();
+    }
+
+    private static void dumpInterfaces(ProtoOutputStream proto, long tag,
+            ArrayMap<String, NetworkIdentitySet> ifaces) {
+        for (int i = 0; i < ifaces.size(); i++) {
+            final long start = proto.start(tag);
+
+            proto.write(NetworkInterfaceProto.INTERFACE, ifaces.keyAt(i));
+            ifaces.valueAt(i).dumpDebug(proto, NetworkInterfaceProto.IDENTITIES);
+
+            proto.end(start);
+        }
+    }
+
+    private NetworkStats readNetworkStatsSummaryDev() {
+        try {
+            return mStatsFactory.readNetworkStatsSummaryDev();
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private NetworkStats readNetworkStatsSummaryXt() {
+        try {
+            return mStatsFactory.readNetworkStatsSummaryXt();
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private NetworkStats readNetworkStatsUidDetail(int uid, String[] ifaces, int tag) {
+        try {
+            return mStatsFactory.readNetworkStatsDetail(uid, ifaces, tag);
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * Return snapshot of current UID statistics, including any
+     * {@link TrafficStats#UID_TETHERING}, video calling data usage, and {@link #mUidOperations}
+     * values.
+     *
+     * @param ifaces A list of interfaces the stats should be restricted to, or
+     *               {@link NetworkStats#INTERFACES_ALL}.
+     */
+    private NetworkStats getNetworkStatsUidDetail(String[] ifaces)
+            throws RemoteException {
+        final NetworkStats uidSnapshot = readNetworkStatsUidDetail(UID_ALL,  ifaces, TAG_ALL);
+
+        // fold tethering stats and operations into uid snapshot
+        final NetworkStats tetherSnapshot = getNetworkStatsTethering(STATS_PER_UID);
+        tetherSnapshot.filter(UID_ALL, ifaces, TAG_ALL);
+        mStatsFactory.apply464xlatAdjustments(uidSnapshot, tetherSnapshot);
+        uidSnapshot.combineAllValues(tetherSnapshot);
+
+        // get a stale copy of uid stats snapshot provided by providers.
+        final NetworkStats providerStats = getNetworkStatsFromProviders(STATS_PER_UID);
+        providerStats.filter(UID_ALL, ifaces, TAG_ALL);
+        mStatsFactory.apply464xlatAdjustments(uidSnapshot, providerStats);
+        uidSnapshot.combineAllValues(providerStats);
+
+        uidSnapshot.combineAllValues(mUidOperations);
+
+        return uidSnapshot;
+    }
+
+    /**
+     * Return snapshot of current non-offloaded tethering statistics. Will return empty
+     * {@link NetworkStats} if any problems are encountered, or queried by {@code STATS_PER_IFACE}
+     * since it is already included by {@link #nativeGetIfaceStat}.
+     * See {@code OffloadTetheringStatsProvider} for offloaded tethering stats.
+     */
+    // TODO: Remove this by implementing {@link NetworkStatsProvider} for non-offloaded
+    //  tethering stats.
+    private @NonNull NetworkStats getNetworkStatsTethering(int how) throws RemoteException {
+         // We only need to return per-UID stats. Per-device stats are already counted by
+        // interface counters.
+        if (how != STATS_PER_UID) {
+            return new NetworkStats(SystemClock.elapsedRealtime(), 0);
+        }
+
+        final NetworkStats stats = new NetworkStats(SystemClock.elapsedRealtime(), 1);
+        try {
+            final TetherStatsParcel[] tetherStatsParcels = mNetd.tetherGetStats();
+            for (TetherStatsParcel tetherStats : tetherStatsParcels) {
+                try {
+                    stats.combineValues(new NetworkStats.Entry(tetherStats.iface, UID_TETHERING,
+                            SET_DEFAULT, TAG_NONE, tetherStats.rxBytes, tetherStats.rxPackets,
+                            tetherStats.txBytes, tetherStats.txPackets, 0L));
+                } catch (ArrayIndexOutOfBoundsException e) {
+                    throw new IllegalStateException("invalid tethering stats " + e);
+                }
+            }
+        } catch (IllegalStateException e) {
+            Log.wtf(TAG, "problem reading network stats", e);
+        }
+        return stats;
+    }
+
+    // TODO: It is copied from ConnectivityService, consider refactor these check permission
+    //  functions to a proper util.
+    private boolean checkAnyPermissionOf(String... permissions) {
+        for (String permission : permissions) {
+            if (mContext.checkCallingOrSelfPermission(permission) == PERMISSION_GRANTED) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void enforceAnyPermissionOf(String... permissions) {
+        if (!checkAnyPermissionOf(permissions)) {
+            throw new SecurityException("Requires one of the following permissions: "
+                    + String.join(", ", permissions) + ".");
+        }
+    }
+
+    /**
+     * Registers a custom provider of {@link android.net.NetworkStats} to combine the network
+     * statistics that cannot be seen by the kernel to system. To unregister, invoke the
+     * {@code unregister()} of the returned callback.
+     *
+     * @param tag a human readable identifier of the custom network stats provider.
+     * @param provider the {@link INetworkStatsProvider} binder corresponding to the
+     *                 {@link NetworkStatsProvider} to be registered.
+     *
+     * @return a {@link INetworkStatsProviderCallback} binder
+     *         interface, which can be used to report events to the system.
+     */
+    public @NonNull INetworkStatsProviderCallback registerNetworkStatsProvider(
+            @NonNull String tag, @NonNull INetworkStatsProvider provider) {
+        enforceAnyPermissionOf(NETWORK_STATS_PROVIDER,
+                NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK);
+        Objects.requireNonNull(provider, "provider is null");
+        Objects.requireNonNull(tag, "tag is null");
+        final NetworkPolicyManager netPolicyManager = mContext
+                .getSystemService(NetworkPolicyManager.class);
+        try {
+            NetworkStatsProviderCallbackImpl callback = new NetworkStatsProviderCallbackImpl(
+                    tag, provider, mStatsProviderSem, mAlertObserver,
+                    mStatsProviderCbList, netPolicyManager);
+            mStatsProviderCbList.add(callback);
+            Log.d(TAG, "registerNetworkStatsProvider from " + callback.mTag + " uid/pid="
+                    + getCallingUid() + "/" + getCallingPid());
+            return callback;
+        } catch (RemoteException e) {
+            Log.e(TAG, "registerNetworkStatsProvider failed", e);
+        }
+        return null;
+    }
+
+    // Collect stats from local cache of providers.
+    private @NonNull NetworkStats getNetworkStatsFromProviders(int how) {
+        final NetworkStats ret = new NetworkStats(0L, 0);
+        invokeForAllStatsProviderCallbacks((cb) -> ret.combineAllValues(cb.getCachedStats(how)));
+        return ret;
+    }
+
+    @FunctionalInterface
+    private interface ThrowingConsumer<S, T extends Throwable> {
+        void accept(S s) throws T;
+    }
+
+    private void invokeForAllStatsProviderCallbacks(
+            @NonNull ThrowingConsumer<NetworkStatsProviderCallbackImpl, RemoteException> task) {
+        for (final NetworkStatsProviderCallbackImpl cb : mStatsProviderCbList) {
+            try {
+                task.accept(cb);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Fail to broadcast to provider: " + cb.mTag, e);
+            }
+        }
+    }
+
+    private static class NetworkStatsProviderCallbackImpl extends INetworkStatsProviderCallback.Stub
+            implements IBinder.DeathRecipient {
+        @NonNull final String mTag;
+
+        @NonNull final INetworkStatsProvider mProvider;
+        @NonNull private final Semaphore mSemaphore;
+        @NonNull final AlertObserver mAlertObserver;
+        @NonNull final CopyOnWriteArrayList<NetworkStatsProviderCallbackImpl> mStatsProviderCbList;
+        @NonNull final NetworkPolicyManager mNetworkPolicyManager;
+
+        @NonNull private final Object mProviderStatsLock = new Object();
+
+        @GuardedBy("mProviderStatsLock")
+        // Track STATS_PER_IFACE and STATS_PER_UID separately.
+        private final NetworkStats mIfaceStats = new NetworkStats(0L, 0);
+        @GuardedBy("mProviderStatsLock")
+        private final NetworkStats mUidStats = new NetworkStats(0L, 0);
+
+        NetworkStatsProviderCallbackImpl(
+                @NonNull String tag, @NonNull INetworkStatsProvider provider,
+                @NonNull Semaphore semaphore,
+                @NonNull AlertObserver alertObserver,
+                @NonNull CopyOnWriteArrayList<NetworkStatsProviderCallbackImpl> cbList,
+                @NonNull NetworkPolicyManager networkPolicyManager)
+                throws RemoteException {
+            mTag = tag;
+            mProvider = provider;
+            mProvider.asBinder().linkToDeath(this, 0);
+            mSemaphore = semaphore;
+            mAlertObserver = alertObserver;
+            mStatsProviderCbList = cbList;
+            mNetworkPolicyManager = networkPolicyManager;
+        }
+
+        @NonNull
+        public NetworkStats getCachedStats(int how) {
+            synchronized (mProviderStatsLock) {
+                NetworkStats stats;
+                switch (how) {
+                    case STATS_PER_IFACE:
+                        stats = mIfaceStats;
+                        break;
+                    case STATS_PER_UID:
+                        stats = mUidStats;
+                        break;
+                    default:
+                        throw new IllegalArgumentException("Invalid type: " + how);
+                }
+                // Callers might be able to mutate the returned object. Return a defensive copy
+                // instead of local reference.
+                return stats.clone();
+            }
+        }
+
+        @Override
+        public void notifyStatsUpdated(int token, @Nullable NetworkStats ifaceStats,
+                @Nullable NetworkStats uidStats) {
+            // TODO: 1. Use token to map ifaces to correct NetworkIdentity.
+            //       2. Store the difference and store it directly to the recorder.
+            synchronized (mProviderStatsLock) {
+                if (ifaceStats != null) mIfaceStats.combineAllValues(ifaceStats);
+                if (uidStats != null) mUidStats.combineAllValues(uidStats);
+            }
+            mSemaphore.release();
+        }
+
+        @Override
+        public void notifyAlertReached() throws RemoteException {
+            // This binder object can only have been obtained by a process that holds
+            // NETWORK_STATS_PROVIDER. Thus, no additional permission check is required.
+            BinderUtils.withCleanCallingIdentity(() ->
+                    mAlertObserver.onQuotaLimitReached(LIMIT_GLOBAL_ALERT, null /* unused */));
+        }
+
+        @Override
+        public void notifyWarningReached() {
+            Log.d(TAG, mTag + ": notifyWarningReached");
+            BinderUtils.withCleanCallingIdentity(() ->
+                    mNetworkPolicyManager.notifyStatsProviderWarningReached());
+        }
+
+        @Override
+        public void notifyLimitReached() {
+            Log.d(TAG, mTag + ": notifyLimitReached");
+            BinderUtils.withCleanCallingIdentity(() ->
+                    mNetworkPolicyManager.notifyStatsProviderLimitReached());
+        }
+
+        @Override
+        public void binderDied() {
+            Log.d(TAG, mTag + ": binderDied");
+            mStatsProviderCbList.remove(this);
+        }
+
+        @Override
+        public void unregister() {
+            Log.d(TAG, mTag + ": unregister");
+            mStatsProviderCbList.remove(this);
+        }
+
+    }
+
+    private void assertSystemReady() {
+        if (!mSystemReady) {
+            throw new IllegalStateException("System not ready");
+        }
+    }
+
+    private class DropBoxNonMonotonicObserver implements NonMonotonicObserver<String> {
+        @Override
+        public void foundNonMonotonic(NetworkStats left, int leftIndex, NetworkStats right,
+                int rightIndex, String cookie) {
+            Log.w(TAG, "Found non-monotonic values; saving to dropbox");
+
+            // record error for debugging
+            final StringBuilder builder = new StringBuilder();
+            builder.append("found non-monotonic " + cookie + " values at left[" + leftIndex
+                    + "] - right[" + rightIndex + "]\n");
+            builder.append("left=").append(left).append('\n');
+            builder.append("right=").append(right).append('\n');
+
+            mContext.getSystemService(DropBoxManager.class).addText(TAG_NETSTATS_ERROR,
+                    builder.toString());
+        }
+
+        @Override
+        public void foundNonMonotonic(
+                NetworkStats stats, int statsIndex, String cookie) {
+            Log.w(TAG, "Found non-monotonic values; saving to dropbox");
+
+            final StringBuilder builder = new StringBuilder();
+            builder.append("Found non-monotonic " + cookie + " values at [" + statsIndex + "]\n");
+            builder.append("stats=").append(stats).append('\n');
+
+            mContext.getSystemService(DropBoxManager.class).addText(TAG_NETSTATS_ERROR,
+                    builder.toString());
+        }
+    }
+
+    /**
+     * Default external settings that read from
+     * {@link android.provider.Settings.Global}.
+     */
+    private static class DefaultNetworkStatsSettings implements NetworkStatsSettings {
+        DefaultNetworkStatsSettings() {}
+
+        @Override
+        public long getPollInterval() {
+            return 30 * MINUTE_IN_MILLIS;
+        }
+        @Override
+        public long getPollDelay() {
+            return DEFAULT_PERFORM_POLL_DELAY_MS;
+        }
+        @Override
+        public long getGlobalAlertBytes(long def) {
+            return def;
+        }
+        @Override
+        public boolean getSampleEnabled() {
+            return true;
+        }
+        @Override
+        public boolean getAugmentEnabled() {
+            return true;
+        }
+        @Override
+        public boolean getCombineSubtypeEnabled() {
+            return false;
+        }
+        @Override
+        public Config getDevConfig() {
+            return new Config(HOUR_IN_MILLIS, 15 * DAY_IN_MILLIS, 90 * DAY_IN_MILLIS);
+        }
+        @Override
+        public Config getXtConfig() {
+            return getDevConfig();
+        }
+        @Override
+        public Config getUidConfig() {
+            return new Config(2 * HOUR_IN_MILLIS, 15 * DAY_IN_MILLIS, 90 * DAY_IN_MILLIS);
+        }
+        @Override
+        public Config getUidTagConfig() {
+            return new Config(2 * HOUR_IN_MILLIS, 5 * DAY_IN_MILLIS, 15 * DAY_IN_MILLIS);
+        }
+        @Override
+        public long getDevPersistBytes(long def) {
+            return def;
+        }
+        @Override
+        public long getXtPersistBytes(long def) {
+            return def;
+        }
+        @Override
+        public long getUidPersistBytes(long def) {
+            return def;
+        }
+        @Override
+        public long getUidTagPersistBytes(long def) {
+            return def;
+        }
+    }
+
+    private static native long nativeGetTotalStat(int type);
+    private static native long nativeGetIfaceStat(String iface, int type);
+    private static native long nativeGetUidStat(int uid, int type);
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsSubscriptionsMonitor.java b/service-t/src/com/android/server/net/NetworkStatsSubscriptionsMonitor.java
new file mode 100644
index 0000000..65ccd20
--- /dev/null
+++ b/service-t/src/com/android/server/net/NetworkStatsSubscriptionsMonitor.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2020 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 android.app.usage.NetworkStatsManager.NETWORK_TYPE_5G_NSA;
+import static android.app.usage.NetworkStatsManager.getCollapsedRatType;
+import static android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED;
+import static android.telephony.TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA;
+import static android.telephony.TelephonyManager.NETWORK_TYPE_LTE;
+
+import android.annotation.NonNull;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyCallback;
+import android.telephony.TelephonyDisplayInfo;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.Pair;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executor;
+
+/**
+ * Helper class that watches for events that are triggered per subscription.
+ */
+@TargetApi(Build.VERSION_CODES.TIRAMISU)
+public class NetworkStatsSubscriptionsMonitor extends
+        SubscriptionManager.OnSubscriptionsChangedListener {
+
+    /**
+     * Interface that this monitor uses to delegate event handling to NetworkStatsService.
+     */
+    public interface Delegate {
+        /**
+         * Notify that the collapsed RAT type has been changed for any subscription. The method
+         * will also be triggered for any existing sub when start and stop monitoring.
+         *
+         * @param subscriberId IMSI of the subscription.
+         * @param collapsedRatType collapsed RAT type.
+         *                     @see android.app.usage.NetworkStatsManager#getCollapsedRatType(int).
+         */
+        void onCollapsedRatTypeChanged(@NonNull String subscriberId, int collapsedRatType);
+    }
+    private final Delegate mDelegate;
+
+    /**
+     * Receivers that watches for {@link TelephonyDisplayInfo} changes for each subscription, to
+     * monitor the transitioning between Radio Access Technology(RAT) types for each sub.
+     */
+    @NonNull
+    private final CopyOnWriteArrayList<RatTypeListener> mRatListeners =
+            new CopyOnWriteArrayList<>();
+
+    @NonNull
+    private final SubscriptionManager mSubscriptionManager;
+    @NonNull
+    private final TelephonyManager mTeleManager;
+
+    @NonNull
+    private final Executor mExecutor;
+
+    NetworkStatsSubscriptionsMonitor(@NonNull Context context,
+            @NonNull Executor executor, @NonNull Delegate delegate) {
+        super();
+        mSubscriptionManager = (SubscriptionManager) context.getSystemService(
+                Context.TELEPHONY_SUBSCRIPTION_SERVICE);
+        mTeleManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+        mExecutor = executor;
+        mDelegate = delegate;
+    }
+
+    @Override
+    public void onSubscriptionsChanged() {
+        // Collect active subId list, hidden subId such as opportunistic subscriptions are
+        // also needed to track CBRS.
+        final List<Integer> newSubs = getActiveSubIdList(mSubscriptionManager);
+
+        // IMSI is needed for every newly added sub. Listener stores subscriberId into it to
+        // prevent binder call to telephony when querying RAT. Keep listener registration with empty
+        // IMSI is meaningless since the RAT type changed is ambiguous for multi-SIM if reported
+        // with empty IMSI. So filter the subs w/o a valid IMSI to prevent such registration.
+        final List<Pair<Integer, String>> filteredNewSubs = new ArrayList<>();
+        for (final int subId : newSubs) {
+            final String subscriberId =
+                    mTeleManager.createForSubscriptionId(subId).getSubscriberId();
+            if (!TextUtils.isEmpty(subscriberId)) {
+                filteredNewSubs.add(new Pair(subId, subscriberId));
+            }
+        }
+
+        for (final Pair<Integer, String> sub : filteredNewSubs) {
+            // Fully match listener with subId and IMSI, since in some rare cases, IMSI might be
+            // suddenly change regardless of subId, such as switch IMSI feature in modem side.
+            // If that happens, register new listener with new IMSI and remove old one later.
+            if (CollectionUtils.any(mRatListeners, it -> it.equalsKey(sub.first, sub.second))) {
+                continue;
+            }
+
+            final RatTypeListener listener = new RatTypeListener(this, sub.first, sub.second);
+            mRatListeners.add(listener);
+
+            // Register listener to the telephony manager that associated with specific sub.
+            mTeleManager.createForSubscriptionId(sub.first)
+                    .registerTelephonyCallback(mExecutor, listener);
+            Log.d(NetworkStatsService.TAG, "RAT type listener registered for sub " + sub.first);
+        }
+
+        for (final RatTypeListener listener : new ArrayList<>(mRatListeners)) {
+            // If there is no subId and IMSI matched the listener, removes it.
+            if (!CollectionUtils.any(filteredNewSubs,
+                    it -> listener.equalsKey(it.first, it.second))) {
+                handleRemoveRatTypeListener(listener);
+            }
+        }
+    }
+
+    @NonNull
+    private List<Integer> getActiveSubIdList(@NonNull SubscriptionManager subscriptionManager) {
+        final ArrayList<Integer> ret = new ArrayList<>();
+        final int[] ids = subscriptionManager.getCompleteActiveSubscriptionIdList();
+        for (int id : ids) ret.add(id);
+        return ret;
+    }
+
+    /**
+     * Get a collapsed RatType for the given subscriberId.
+     *
+     * @param subscriberId the target subscriberId
+     * @return collapsed RatType for the given subscriberId
+     */
+    public int getRatTypeForSubscriberId(@NonNull String subscriberId) {
+        final int index = CollectionUtils.indexOf(mRatListeners,
+                it -> TextUtils.equals(subscriberId, it.mSubscriberId));
+        return index != -1 ? mRatListeners.get(index).mLastCollapsedRatType
+                : TelephonyManager.NETWORK_TYPE_UNKNOWN;
+    }
+
+    /**
+     * Start monitoring events that triggered per subscription.
+     */
+    public void start() {
+        mSubscriptionManager.addOnSubscriptionsChangedListener(mExecutor, this);
+    }
+
+    /**
+     * Unregister subscription changes and all listeners for each subscription.
+     */
+    public void stop() {
+        mSubscriptionManager.removeOnSubscriptionsChangedListener(this);
+
+        for (final RatTypeListener listener : new ArrayList<>(mRatListeners)) {
+            handleRemoveRatTypeListener(listener);
+        }
+    }
+
+    private void handleRemoveRatTypeListener(@NonNull RatTypeListener listener) {
+        mTeleManager.createForSubscriptionId(listener.mSubId)
+                .unregisterTelephonyCallback(listener);
+        Log.d(NetworkStatsService.TAG, "RAT type listener unregistered for sub " + listener.mSubId);
+        mRatListeners.remove(listener);
+
+        // Removal of subscriptions doesn't generate RAT changed event, fire it for every
+        // RatTypeListener.
+        mDelegate.onCollapsedRatTypeChanged(
+                listener.mSubscriberId, TelephonyManager.NETWORK_TYPE_UNKNOWN);
+    }
+
+    static class RatTypeListener extends TelephonyCallback
+            implements TelephonyCallback.DisplayInfoListener {
+        // Unique id for the subscription. See {@link SubscriptionInfo#getSubscriptionId}.
+        @NonNull
+        private final int mSubId;
+
+        // IMSI to identifying the corresponding network from {@link NetworkState}.
+        // See {@link TelephonyManager#getSubscriberId}.
+        @NonNull
+        private final String mSubscriberId;
+
+        private volatile int mLastCollapsedRatType = TelephonyManager.NETWORK_TYPE_UNKNOWN;
+        @NonNull
+        private final NetworkStatsSubscriptionsMonitor mMonitor;
+
+        RatTypeListener(@NonNull NetworkStatsSubscriptionsMonitor monitor, int subId,
+                @NonNull String subscriberId) {
+            mSubId = subId;
+            mSubscriberId = subscriberId;
+            mMonitor = monitor;
+        }
+
+        @Override
+        public void onDisplayInfoChanged(TelephonyDisplayInfo displayInfo) {
+            // In 5G SA (Stand Alone) mode, the primary cell itself will be 5G hence telephony
+            // would report RAT = 5G_NR.
+            // However, in 5G NSA (Non Stand Alone) mode, the primary cell is still LTE and
+            // network allocates a secondary 5G cell so telephony reports RAT = LTE along with
+            // NR state as connected. In such case, attributes the data usage to NR.
+            // See b/160727498.
+            final boolean is5GNsa = displayInfo.getNetworkType() == NETWORK_TYPE_LTE
+                    && (displayInfo.getOverrideNetworkType() == OVERRIDE_NETWORK_TYPE_NR_NSA
+                    || displayInfo.getOverrideNetworkType() == OVERRIDE_NETWORK_TYPE_NR_ADVANCED);
+
+            final int networkType =
+                    (is5GNsa ? NETWORK_TYPE_5G_NSA : displayInfo.getNetworkType());
+            final int collapsedRatType = getCollapsedRatType(networkType);
+            if (collapsedRatType == mLastCollapsedRatType) return;
+
+            if (NetworkStatsService.LOGD) {
+                Log.d(NetworkStatsService.TAG, "subtype changed for sub(" + mSubId + "): "
+                        + mLastCollapsedRatType + " -> " + collapsedRatType);
+            }
+            mLastCollapsedRatType = collapsedRatType;
+            mMonitor.mDelegate.onCollapsedRatTypeChanged(mSubscriberId, mLastCollapsedRatType);
+        }
+
+        @VisibleForTesting
+        public int getSubId() {
+            return mSubId;
+        }
+
+        boolean equalsKey(int subId, @NonNull String subscriberId) {
+            return mSubId == subId && TextUtils.equals(mSubscriberId, subscriberId);
+        }
+    }
+}
diff --git a/service-t/src/com/android/server/net/StatsMapKey.java b/service-t/src/com/android/server/net/StatsMapKey.java
new file mode 100644
index 0000000..ea8d836
--- /dev/null
+++ b/service-t/src/com/android/server/net/StatsMapKey.java
@@ -0,0 +1,46 @@
+/*
+ * 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.net;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Field;
+import com.android.net.module.util.Struct.Type;
+
+/**
+ * Key for both stats maps.
+ */
+public class StatsMapKey extends Struct {
+    @Field(order = 0, type = Type.U32)
+    public final long uid;
+
+    @Field(order = 1, type = Type.U32)
+    public final long tag;
+
+    @Field(order = 2, type = Type.U32)
+    public final long counterSet;
+
+    @Field(order = 3, type = Type.U32)
+    public final long ifaceIndex;
+
+    public StatsMapKey(final long uid, final long tag, final long counterSet,
+            final long ifaceIndex) {
+        this.uid = uid;
+        this.tag = tag;
+        this.counterSet = counterSet;
+        this.ifaceIndex = ifaceIndex;
+    }
+}
diff --git a/service-t/src/com/android/server/net/StatsMapValue.java b/service-t/src/com/android/server/net/StatsMapValue.java
new file mode 100644
index 0000000..48f26ce
--- /dev/null
+++ b/service-t/src/com/android/server/net/StatsMapValue.java
@@ -0,0 +1,46 @@
+/*
+ * 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.net;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Field;
+import com.android.net.module.util.Struct.Type;
+
+/**
+ * Value used for both stats maps and uid stats map.
+ */
+public class StatsMapValue extends Struct {
+    @Field(order = 0, type = Type.U63)
+    public final long rxPackets;
+
+    @Field(order = 1, type = Type.U63)
+    public final long rxBytes;
+
+    @Field(order = 2, type = Type.U63)
+    public final long txPackets;
+
+    @Field(order = 3, type = Type.U63)
+    public final long txBytes;
+
+    public StatsMapValue(final long rxPackets, final long rxBytes, final long txPackets,
+            final long txBytes) {
+        this.rxPackets = rxPackets;
+        this.rxBytes = rxBytes;
+        this.txPackets = txPackets;
+        this.txBytes = txBytes;
+    }
+}
diff --git a/service-t/src/com/android/server/net/UidStatsMapKey.java b/service-t/src/com/android/server/net/UidStatsMapKey.java
new file mode 100644
index 0000000..2849f94
--- /dev/null
+++ b/service-t/src/com/android/server/net/UidStatsMapKey.java
@@ -0,0 +1,33 @@
+/*
+ * 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.net;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Field;
+import com.android.net.module.util.Struct.Type;
+
+/**
+ * Key for uid stats map.
+ */
+public class UidStatsMapKey extends Struct {
+    @Field(order = 0, type = Type.U32)
+    public final long uid;
+
+    public UidStatsMapKey(final long uid) {
+        this.uid = uid;
+    }
+}
diff --git a/service/Android.bp b/service/Android.bp
index ef5244c..0b8b25f 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -119,7 +119,7 @@
         "networkstack-client",
         "PlatformProperties",
         "service-connectivity-protos",
-        "NetworkStackApiStableShims",
+        "NetworkStackApiCurrentShims",
     ],
     apex_available: [
         "com.android.tethering",
@@ -166,6 +166,11 @@
     apex_available: [
         "com.android.tethering",
     ],
+    optimize: {
+        enabled: true,
+        shrink: true,
+        proguard_flags_files: ["proguard.flags"],
+    },
     lint: { strict_updatability_linting: true },
 }
 
diff --git a/service/jarjar-rules.txt b/service/jarjar-rules.txt
index e90b29b..5d51fdd 100644
--- a/service/jarjar-rules.txt
+++ b/service/jarjar-rules.txt
@@ -105,6 +105,9 @@
 # From the API shims
 rule com.android.networkstack.apishim.** com.android.connectivity.@0
 
+# From fast-pair-lite-protos
+rule service.proto.** com.android.server.nearby.@0
+
 # From filegroup framework-connectivity-protos
 rule android.service.*Proto com.android.connectivity.@0
 
diff --git a/service/proguard.flags b/service/proguard.flags
new file mode 100644
index 0000000..2b20ddd
--- /dev/null
+++ b/service/proguard.flags
@@ -0,0 +1,21 @@
+# Make sure proguard keeps all connectivity classes
+# TODO: instead of keeping everything, consider listing only "entry points"
+# (service loader, JNI registered methods, etc) and letting the optimizer do its job
+-keep class android.net.** { *; }
+-keep class com.android.connectivity.** { *; }
+-keep class com.android.net.** { *; }
+-keep class com.android.server.** { *; }
+
+# Prevent proguard from stripping out any nearby-service and fast-pair-lite-protos fields.
+# TODO: This could be optimized in the future to only keep the critical
+# entry points and then let proguard strip out any unused code within
+# the service. "com.android.server.nearby.service.proto" must be kept to prevent proguard
+# from stripping out any fast-pair-lite-protos fields.
+-keep class com.android.server.nearby.** { *; }
+
+# The lite proto runtime uses reflection to access fields based on the names in
+# the schema, keep all the fields.
+# This replicates the base proguard rule used by the build by default
+# (proguard_basic_keeps.flags), but needs to be specified here because the
+# com.google.protobuf package is jarjared to the below package.
+-keepclassmembers class * extends com.android.connectivity.com.google.protobuf.MessageLite { <fields>; }
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index ab5f5d3..b4cc41a 100644
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -34,6 +34,9 @@
 import static android.net.ConnectivityManager.BLOCKED_REASON_LOCKDOWN_VPN;
 import static android.net.ConnectivityManager.BLOCKED_REASON_NONE;
 import static android.net.ConnectivityManager.CONNECTIVITY_ACTION;
+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.TYPE_BLUETOOTH;
 import static android.net.ConnectivityManager.TYPE_ETHERNET;
 import static android.net.ConnectivityManager.TYPE_MOBILE;
@@ -11218,17 +11221,43 @@
     }
 
     @Override
-    public void updateFirewallRule(final int chain, final int uid, final boolean allow) {
+    public void setUidFirewallRule(final int chain, final int uid, final int rule) {
         enforceNetworkStackOrSettingsPermission();
 
+        // There are only two type of firewall rule: FIREWALL_RULE_ALLOW or FIREWALL_RULE_DENY
+        int firewallRule = getFirewallRuleType(chain, rule);
+
+        if (firewallRule != FIREWALL_RULE_ALLOW && firewallRule != FIREWALL_RULE_DENY) {
+            throw new IllegalArgumentException("setUidFirewallRule with invalid rule: " + rule);
+        }
+
         try {
-            mBpfNetMaps.setUidRule(chain, uid,
-                    allow ? INetd.FIREWALL_RULE_ALLOW : INetd.FIREWALL_RULE_DENY);
+            mBpfNetMaps.setUidRule(chain, uid, firewallRule);
         } catch (ServiceSpecificException e) {
             throw new IllegalStateException(e);
         }
     }
 
+    private int getFirewallRuleType(int chain, int rule) {
+        final int defaultRule;
+        switch (chain) {
+            case ConnectivityManager.FIREWALL_CHAIN_STANDBY:
+                defaultRule = FIREWALL_RULE_ALLOW;
+                break;
+            case ConnectivityManager.FIREWALL_CHAIN_DOZABLE:
+            case ConnectivityManager.FIREWALL_CHAIN_POWERSAVE:
+            case ConnectivityManager.FIREWALL_CHAIN_RESTRICTED:
+            case ConnectivityManager.FIREWALL_CHAIN_LOW_POWER_STANDBY:
+                defaultRule = FIREWALL_RULE_DENY;
+                break;
+            default:
+                throw new IllegalArgumentException("Unsupported firewall chain: " + chain);
+        }
+        if (rule == FIREWALL_RULE_DEFAULT) rule = defaultRule;
+
+        return rule;
+    }
+
     @Override
     public void setFirewallChainEnabled(final int chain, final boolean enable) {
         enforceNetworkStackOrSettingsPermission();
diff --git a/tests/common/java/android/net/netstats/NetworkStatsCollectionTest.kt b/tests/common/java/android/net/netstats/NetworkStatsCollectionTest.kt
new file mode 100644
index 0000000..ca0e5ed
--- /dev/null
+++ b/tests/common/java/android/net/netstats/NetworkStatsCollectionTest.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.net.netstats
+
+import android.net.NetworkIdentitySet
+import android.net.NetworkStatsCollection
+import android.net.NetworkStatsHistory
+import androidx.test.filters.SmallTest
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.SC_V2
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import kotlin.test.assertEquals
+import kotlin.test.fail
+
+@ConnectivityModuleTest
+@RunWith(JUnit4::class)
+@SmallTest
+class NetworkStatsCollectionTest {
+    @Rule
+    @JvmField
+    val ignoreRule = DevSdkIgnoreRule(ignoreClassUpTo = SC_V2)
+
+    @Test
+    fun testBuilder() {
+        val ident = NetworkIdentitySet()
+        val key1 = NetworkStatsCollection.Key(ident, /* uid */ 0, /* set */ 0, /* tag */ 0)
+        val key2 = NetworkStatsCollection.Key(ident, /* uid */ 1, /* set */ 0, /* tag */ 0)
+        val bucketDuration = 10L
+        val entry1 = NetworkStatsHistory.Entry(10, 10, 40, 4, 50, 5, 60)
+        val entry2 = NetworkStatsHistory.Entry(30, 10, 3, 41, 7, 1, 0)
+        val history1 = NetworkStatsHistory.Builder(10, 5)
+                .addEntry(entry1)
+                .addEntry(entry2)
+                .build()
+        val history2 = NetworkStatsHistory(10, 5)
+        val actualCollection = NetworkStatsCollection.Builder(bucketDuration)
+                .addEntry(key1, history1)
+                .addEntry(key2, history2)
+                .build()
+
+        // The builder will omit any entry with empty history. Thus, only history1
+        // is expected in the result collection.
+        val actualEntries = actualCollection.entries
+        assertEquals(1, actualEntries.size)
+        val actualHistory = actualEntries[key1] ?: fail("There should be an entry for $key1")
+        assertEquals(history1.entries, actualHistory.entries)
+    }
+}
\ No newline at end of file
diff --git a/tests/common/java/android/net/netstats/NetworkStatsHistoryTest.kt b/tests/common/java/android/net/netstats/NetworkStatsHistoryTest.kt
new file mode 100644
index 0000000..c2654c5
--- /dev/null
+++ b/tests/common/java/android/net/netstats/NetworkStatsHistoryTest.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.net.netstats
+
+import android.net.NetworkStatsHistory
+import android.text.format.DateUtils
+import androidx.test.filters.SmallTest
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.SC_V2
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import kotlin.test.assertEquals
+
+@ConnectivityModuleTest
+@RunWith(JUnit4::class)
+@SmallTest
+class NetworkStatsHistoryTest {
+    @Rule
+    @JvmField
+    val ignoreRule = DevSdkIgnoreRule(ignoreClassUpTo = SC_V2)
+
+    @Test
+    fun testBuilder() {
+        val entry1 = NetworkStatsHistory.Entry(10, 30, 40, 4, 50, 5, 60)
+        val entry2 = NetworkStatsHistory.Entry(30, 15, 3, 41, 7, 1, 0)
+        val entry3 = NetworkStatsHistory.Entry(7, 301, 11, 14, 31, 2, 80)
+        val statsEmpty = NetworkStatsHistory
+                .Builder(DateUtils.HOUR_IN_MILLIS, /* initialCapacity */ 10).build()
+        assertEquals(0, statsEmpty.entries.size)
+        assertEquals(DateUtils.HOUR_IN_MILLIS, statsEmpty.bucketDuration)
+        val statsSingle = NetworkStatsHistory
+                .Builder(DateUtils.HOUR_IN_MILLIS, /* initialCapacity */ 8)
+                .addEntry(entry1)
+                .build()
+        statsSingle.assertEntriesEqual(entry1)
+        assertEquals(DateUtils.HOUR_IN_MILLIS, statsSingle.bucketDuration)
+        val statsMultiple = NetworkStatsHistory
+                .Builder(DateUtils.SECOND_IN_MILLIS, /* initialCapacity */ 0)
+                .addEntry(entry1).addEntry(entry2).addEntry(entry3)
+                .build()
+        assertEquals(DateUtils.SECOND_IN_MILLIS, statsMultiple.bucketDuration)
+        statsMultiple.assertEntriesEqual(entry1, entry2, entry3)
+    }
+
+    fun NetworkStatsHistory.assertEntriesEqual(vararg entries: NetworkStatsHistory.Entry) {
+        assertEquals(entries.size, this.entries.size)
+        entries.forEachIndexed { i, element ->
+            assertEquals(element, this.entries[i])
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/common/java/android/net/netstats/NetworkTemplateTest.kt b/tests/common/java/android/net/netstats/NetworkTemplateTest.kt
new file mode 100644
index 0000000..192694b
--- /dev/null
+++ b/tests/common/java/android/net/netstats/NetworkTemplateTest.kt
@@ -0,0 +1,205 @@
+/*
+ * 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.net.netstats
+
+import android.net.NetworkStats.DEFAULT_NETWORK_ALL
+import android.net.NetworkStats.METERED_ALL
+import android.net.NetworkStats.METERED_YES
+import android.net.NetworkStats.ROAMING_YES
+import android.net.NetworkStats.ROAMING_ALL
+import android.net.NetworkTemplate
+import android.net.NetworkTemplate.MATCH_BLUETOOTH
+import android.net.NetworkTemplate.MATCH_CARRIER
+import android.net.NetworkTemplate.MATCH_ETHERNET
+import android.net.NetworkTemplate.MATCH_MOBILE
+import android.net.NetworkTemplate.MATCH_MOBILE_WILDCARD
+import android.net.NetworkTemplate.MATCH_PROXY
+import android.net.NetworkTemplate.MATCH_WIFI
+import android.net.NetworkTemplate.MATCH_WIFI_WILDCARD
+import android.net.NetworkTemplate.NETWORK_TYPE_ALL
+import android.net.NetworkTemplate.OEM_MANAGED_ALL
+import android.telephony.TelephonyManager
+import com.android.net.module.util.NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_ALL
+import com.android.net.module.util.NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.SC_V2
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+private const val TEST_IMSI1 = "imsi"
+private const val TEST_WIFI_KEY1 = "wifiKey1"
+private const val TEST_WIFI_KEY2 = "wifiKey2"
+
+@RunWith(JUnit4::class)
+@ConnectivityModuleTest
+class NetworkTemplateTest {
+    @Rule
+    @JvmField
+    val ignoreRule = DevSdkIgnoreRule(ignoreClassUpTo = SC_V2)
+
+    @Test
+    fun testBuilderMatchRules() {
+        // Verify unknown match rules cannot construct templates.
+        listOf(Integer.MIN_VALUE, -1, Integer.MAX_VALUE).forEach {
+            assertFailsWith<IllegalArgumentException> {
+                NetworkTemplate.Builder(it).build()
+            }
+        }
+
+        // Verify hidden match rules cannot construct templates.
+        listOf(MATCH_WIFI_WILDCARD, MATCH_MOBILE_WILDCARD, MATCH_PROXY).forEach {
+            assertFailsWith<IllegalArgumentException> {
+                NetworkTemplate.Builder(it).build()
+            }
+        }
+
+        // Verify template which matches metered cellular and carrier networks with
+        // the given IMSI. See buildTemplateMobileAll and buildTemplateCarrierMetered.
+        listOf(MATCH_MOBILE, MATCH_CARRIER).forEach { matchRule ->
+            NetworkTemplate.Builder(matchRule).setSubscriberIds(setOf(TEST_IMSI1))
+                    .setMeteredness(METERED_YES).build().let {
+                        val expectedTemplate = NetworkTemplate(matchRule, TEST_IMSI1,
+                                arrayOf(TEST_IMSI1), arrayOf<String>(), METERED_YES,
+                                ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                                OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_EXACT)
+                        assertEquals(expectedTemplate, it)
+                    }
+        }
+
+        // Verify template which matches roaming cellular and carrier networks with
+        // the given IMSI.
+        listOf(MATCH_MOBILE, MATCH_CARRIER).forEach { matchRule ->
+            NetworkTemplate.Builder(matchRule).setSubscriberIds(setOf(TEST_IMSI1))
+                    .setRoaming(ROAMING_YES).setMeteredness(METERED_YES).build().let {
+                        val expectedTemplate = NetworkTemplate(matchRule, TEST_IMSI1,
+                                arrayOf(TEST_IMSI1), arrayOf<String>(), METERED_YES,
+                                ROAMING_YES, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                                OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_EXACT)
+                        assertEquals(expectedTemplate, it)
+                    }
+        }
+
+        // Verify carrier template cannot be created without IMSI.
+        assertFailsWith<IllegalArgumentException> {
+            NetworkTemplate.Builder(MATCH_CARRIER).build()
+        }
+
+        // Verify template which matches metered cellular networks,
+        // regardless of IMSI. See buildTemplateMobileWildcard.
+        NetworkTemplate.Builder(MATCH_MOBILE).setMeteredness(METERED_YES).build().let {
+            val expectedTemplate = NetworkTemplate(MATCH_MOBILE_WILDCARD, null /*subscriberId*/,
+                    null /*subscriberIds*/, arrayOf<String>(),
+                    METERED_YES, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
+            assertEquals(expectedTemplate, it)
+        }
+
+        // Verify template which matches metered cellular networks and ratType.
+        // See NetworkTemplate#buildTemplateMobileWithRatType.
+        NetworkTemplate.Builder(MATCH_MOBILE).setSubscriberIds(setOf(TEST_IMSI1))
+                .setMeteredness(METERED_YES).setRatType(TelephonyManager.NETWORK_TYPE_UMTS)
+                .build().let {
+                    val expectedTemplate = NetworkTemplate(MATCH_MOBILE, TEST_IMSI1,
+                            arrayOf(TEST_IMSI1), arrayOf<String>(), METERED_YES,
+                            ROAMING_ALL, DEFAULT_NETWORK_ALL, TelephonyManager.NETWORK_TYPE_UMTS,
+                            OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_EXACT)
+                    assertEquals(expectedTemplate, it)
+                }
+
+        // Verify template which matches all wifi networks,
+        // regardless of Wifi Network Key. See buildTemplateWifiWildcard and buildTemplateWifi.
+        NetworkTemplate.Builder(MATCH_WIFI).build().let {
+            val expectedTemplate = NetworkTemplate(MATCH_WIFI_WILDCARD, null /*subscriberId*/,
+                    null /*subscriberIds*/, arrayOf<String>(),
+                    METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
+            assertEquals(expectedTemplate, it)
+        }
+
+        // Verify template which matches wifi networks with the given Wifi Network Key.
+        // See buildTemplateWifi(wifiNetworkKey).
+        NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(setOf(TEST_WIFI_KEY1)).build().let {
+            val expectedTemplate = NetworkTemplate(MATCH_WIFI, null /*subscriberId*/,
+                    null /*subscriberIds*/, arrayOf(TEST_WIFI_KEY1),
+                    METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
+            assertEquals(expectedTemplate, it)
+        }
+
+        // Verify template which matches all wifi networks with the
+        // given Wifi Network Key, and IMSI. See buildTemplateWifi(wifiNetworkKey, subscriberId).
+        NetworkTemplate.Builder(MATCH_WIFI).setSubscriberIds(setOf(TEST_IMSI1))
+                .setWifiNetworkKeys(setOf(TEST_WIFI_KEY1)).build().let {
+                    val expectedTemplate = NetworkTemplate(MATCH_WIFI, TEST_IMSI1,
+                            arrayOf(TEST_IMSI1), arrayOf(TEST_WIFI_KEY1),
+                            METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                            OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_EXACT)
+                    assertEquals(expectedTemplate, it)
+                }
+
+        // Verify template which matches ethernet and bluetooth networks.
+        // See buildTemplateEthernet and buildTemplateBluetooth.
+        listOf(MATCH_ETHERNET, MATCH_BLUETOOTH).forEach { matchRule ->
+            NetworkTemplate.Builder(matchRule).build().let {
+                val expectedTemplate = NetworkTemplate(matchRule, null /*subscriberId*/,
+                        null /*subscriberIds*/, arrayOf<String>(),
+                        METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                        OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
+                assertEquals(expectedTemplate, it)
+            }
+        }
+    }
+
+    @Test
+    fun testBuilderWifiNetworkKeys() {
+        // Verify template builder which generates same template with the given different
+        // sequence keys.
+        NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(
+                setOf(TEST_WIFI_KEY1, TEST_WIFI_KEY2)).build().let {
+            val expectedTemplate = NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(
+                    setOf(TEST_WIFI_KEY2, TEST_WIFI_KEY1)).build()
+            assertEquals(expectedTemplate, it)
+        }
+
+        // Verify template which matches non-wifi networks with the given key is invalid.
+        listOf(MATCH_MOBILE, MATCH_CARRIER, MATCH_ETHERNET, MATCH_BLUETOOTH, -1,
+                Integer.MAX_VALUE).forEach { matchRule ->
+            assertFailsWith<IllegalArgumentException> {
+                NetworkTemplate.Builder(matchRule).setWifiNetworkKeys(setOf(TEST_WIFI_KEY1)).build()
+            }
+        }
+
+        // Verify template which matches wifi networks with the given null key is invalid.
+        assertFailsWith<IllegalArgumentException> {
+            NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(setOf(null)).build()
+        }
+
+        // Verify template which matches wifi wildcard with the given empty key set.
+        NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(setOf<String>()).build().let {
+            val expectedTemplate = NetworkTemplate(MATCH_WIFI_WILDCARD, null /*subscriberId*/,
+                    arrayOf<String>() /*subscriberIds*/, arrayOf<String>(),
+                    METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
+                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
+            assertEquals(expectedTemplate, it)
+        }
+    }
+}
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 a840242..9894444 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
@@ -53,6 +53,7 @@
 import android.os.BatteryManager;
 import android.os.Binder;
 import android.os.Bundle;
+import android.os.RemoteCallback;
 import android.os.SystemClock;
 import android.provider.DeviceConfig;
 import android.service.notification.NotificationListenerService;
@@ -140,6 +141,7 @@
 
     private static final int ACTIVITY_NETWORK_STATE_TIMEOUT_MS = 6_000;
     private static final int JOB_NETWORK_STATE_TIMEOUT_MS = 10_000;
+    private static final int LAUNCH_ACTIVITY_TIMEOUT_MS = 10_000;
 
     // Must be higher than NETWORK_TIMEOUT_MS
     private static final int ORDERED_BROADCAST_TIMEOUT_MS = NETWORK_TIMEOUT_MS * 4;
@@ -801,6 +803,22 @@
         mDeviceIdleDeviceConfigStateHelper.restoreOriginalValues();
     }
 
+    protected void launchActivity() throws Exception {
+        turnScreenOn();
+        final CountDownLatch latch = new CountDownLatch(1);
+        final Intent launchIntent = getIntentForComponent(TYPE_COMPONENT_ACTIVTIY);
+        final RemoteCallback callback = new RemoteCallback(result -> latch.countDown());
+        launchIntent.putExtra(Intent.EXTRA_REMOTE_CALLBACK, callback);
+        mContext.startActivity(launchIntent);
+        // There might be a race when app2 is launched but ACTION_FINISH_ACTIVITY has not registered
+        // before test calls finishActivity(). When the issue is happened, there is no way to fix
+        // it, so have a callback design to make sure that the app is launched completely and
+        // ACTION_FINISH_ACTIVITY will be registered before leaving this method.
+        if (!latch.await(LAUNCH_ACTIVITY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+            fail("Timed out waiting for launching activity");
+        }
+    }
+
     protected void launchComponentAndAssertNetworkAccess(int type) throws Exception {
         launchComponentAndAssertNetworkAccess(type, true);
     }
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyManagerTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyManagerTest.java
index ad7ec9e..a0d88c9 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyManagerTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/NetworkPolicyManagerTest.java
@@ -18,37 +18,28 @@
 
 import static android.os.Process.SYSTEM_UID;
 
+import static com.android.cts.net.hostside.NetworkPolicyTestUtils.assertIsUidRestrictedOnMeteredNetworks;
 import static com.android.cts.net.hostside.NetworkPolicyTestUtils.assertNetworkingBlockedStatusForUid;
-import static com.android.cts.net.hostside.NetworkPolicyTestUtils.canChangeActiveNetworkMeteredness;
 import static com.android.cts.net.hostside.NetworkPolicyTestUtils.isUidNetworkingBlocked;
-import static com.android.cts.net.hostside.NetworkPolicyTestUtils.isUidRestrictedOnMeteredNetworks;
 import static com.android.cts.net.hostside.NetworkPolicyTestUtils.setRestrictBackground;
 import static com.android.cts.net.hostside.Property.BATTERY_SAVER_MODE;
 import static com.android.cts.net.hostside.Property.DATA_SAVER_MODE;
 
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assume.assumeTrue;
 
 import org.junit.After;
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
 
 public class NetworkPolicyManagerTest extends AbstractRestrictBackgroundNetworkTestCase {
     private static final boolean METERED = true;
     private static final boolean NON_METERED = false;
 
-    @Rule
-    public final MeterednessConfigurationRule mMeterednessConfiguration =
-            new MeterednessConfigurationRule();
-
     @Before
     public void setUp() throws Exception {
         super.setUp();
 
-        assumeTrue(canChangeActiveNetworkMeteredness());
-
         registerBroadcastReceiver();
 
         removeRestrictBackgroundWhitelist(mUid);
@@ -145,13 +136,14 @@
             removeRestrictBackgroundWhitelist(mUid);
 
             // Make TEST_APP2_PKG go to foreground and mUid will be allowed temporarily.
-            launchComponentAndAssertNetworkAccess(TYPE_COMPONENT_ACTIVTIY);
+            launchActivity();
             assertForegroundState();
             assertNetworkingBlockedStatusForUid(mUid, METERED,
                     false /* expectedResult */); // Match NTWK_ALLOWED_TMP_ALLOWLIST
 
             // Back to background.
             finishActivity();
+            assertBackgroundState();
             assertNetworkingBlockedStatusForUid(mUid, METERED,
                     true /* expectedResult */); // Match NTWK_BLOCKED_BG_RESTRICT
         } finally {
@@ -222,26 +214,27 @@
             // enabled and mUid is not in the restrict background whitelist and TEST_APP2_PKG is not
             // in the foreground. For other cases, it will return false.
             setRestrictBackground(true);
-            assertTrue(isUidRestrictedOnMeteredNetworks(mUid));
+            assertIsUidRestrictedOnMeteredNetworks(mUid, true /* expectedResult */);
 
             // Make TEST_APP2_PKG go to foreground and isUidRestrictedOnMeteredNetworks() will
             // return false.
-            launchComponentAndAssertNetworkAccess(TYPE_COMPONENT_ACTIVTIY);
+            launchActivity();
             assertForegroundState();
-            assertFalse(isUidRestrictedOnMeteredNetworks(mUid));
+            assertIsUidRestrictedOnMeteredNetworks(mUid, false /* expectedResult */);
             // Back to background.
             finishActivity();
+            assertBackgroundState();
 
             // Add mUid into restrict background whitelist and isUidRestrictedOnMeteredNetworks()
             // will return false.
             addRestrictBackgroundWhitelist(mUid);
-            assertFalse(isUidRestrictedOnMeteredNetworks(mUid));
+            assertIsUidRestrictedOnMeteredNetworks(mUid, false /* expectedResult */);
             removeRestrictBackgroundWhitelist(mUid);
         } finally {
             // Restrict background is disabled and isUidRestrictedOnMeteredNetworks() will return
             // false.
             setRestrictBackground(false);
-            assertFalse(isUidRestrictedOnMeteredNetworks(mUid));
+            assertIsUidRestrictedOnMeteredNetworks(mUid, false /* expectedResult */);
         }
     }
 }
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 89a9bd6..9c4a348 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
@@ -439,6 +439,11 @@
         PollingCheck.waitFor(() -> (expectedResult == isUidNetworkingBlocked(uid, metered)));
     }
 
+    public static void assertIsUidRestrictedOnMeteredNetworks(int uid, boolean expectedResult)
+            throws Exception {
+        PollingCheck.waitFor(() -> (expectedResult == isUidRestrictedOnMeteredNetworks(uid)));
+    }
+
     public static boolean isUidNetworkingBlocked(int uid, boolean meteredNetwork) {
         final UiAutomation uiAutomation = getInstrumentation().getUiAutomation();
         try {
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/RestrictedModeTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/RestrictedModeTest.java
index 5f0f6d6..4266aad 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/RestrictedModeTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/RestrictedModeTest.java
@@ -24,6 +24,7 @@
     @Before
     public void setUp() throws Exception {
         super.setUp();
+        setRestrictedNetworkingMode(false);
     }
 
     @After
@@ -34,8 +35,6 @@
 
     @Test
     public void testNetworkAccess() throws Exception {
-        setRestrictedNetworkingMode(false);
-
         // go to foreground state and enable restricted mode
         launchComponentAndAssertNetworkAccess(TYPE_COMPONENT_ACTIVTIY);
         setRestrictedNetworkingMode(true);
@@ -54,4 +53,18 @@
         finishActivity();
         assertBackgroundNetworkAccess(true);
     }
+
+    @Test
+    public void testNetworkAccess_withBatterySaver() throws Exception {
+        setBatterySaverMode(true);
+        addPowerSaveModeWhitelist(TEST_APP2_PKG);
+        assertBackgroundNetworkAccess(true);
+
+        setRestrictedNetworkingMode(true);
+        // App would be denied network access since Restricted mode is on.
+        assertBackgroundNetworkAccess(false);
+        setRestrictedNetworkingMode(false);
+        // Given that Restricted mode is turned off, app should be able to access network again.
+        assertBackgroundNetworkAccess(true);
+    }
 }
diff --git a/tests/cts/hostside/app2/AndroidManifest.xml b/tests/cts/hostside/app2/AndroidManifest.xml
index 6c9b469..ff7240d 100644
--- a/tests/cts/hostside/app2/AndroidManifest.xml
+++ b/tests/cts/hostside/app2/AndroidManifest.xml
@@ -22,6 +22,7 @@
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
     <uses-permission android:name="android.permission.INTERNET"/>
     <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
 
     <!--
      This application is used to listen to RESTRICT_BACKGROUND_CHANGED intents and store
diff --git a/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyActivity.java b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyActivity.java
index 9fdb9c9..eb7dca7 100644
--- a/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyActivity.java
+++ b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyActivity.java
@@ -17,7 +17,6 @@
 
 import static com.android.cts.net.hostside.app2.Common.ACTION_FINISH_ACTIVITY;
 import static com.android.cts.net.hostside.app2.Common.TAG;
-import static com.android.cts.net.hostside.app2.Common.TEST_PKG;
 import static com.android.cts.net.hostside.app2.Common.TYPE_COMPONENT_ACTIVTY;
 
 import android.app.Activity;
@@ -25,13 +24,10 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
-import android.os.AsyncTask;
 import android.os.Bundle;
-import android.os.RemoteException;
+import android.os.RemoteCallback;
 import android.util.Log;
 
-import com.android.cts.net.hostside.INetworkStateObserver;
-
 /**
  * Activity used to bring process to foreground.
  */
@@ -51,7 +47,13 @@
                 MyActivity.this.finish();
             }
         };
-        registerReceiver(finishCommandReceiver, new IntentFilter(ACTION_FINISH_ACTIVITY));
+        registerReceiver(finishCommandReceiver, new IntentFilter(ACTION_FINISH_ACTIVITY),
+                Context.RECEIVER_EXPORTED);
+        final RemoteCallback callback = getIntent().getParcelableExtra(
+                Intent.EXTRA_REMOTE_CALLBACK);
+        if (callback != null) {
+            callback.sendResult(null);
+        }
     }
 
     @Override
diff --git a/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyJobService.java b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyJobService.java
index 51c3157..8c112b6 100644
--- a/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyJobService.java
+++ b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/MyJobService.java
@@ -56,7 +56,8 @@
                 }
             }
         };
-        registerReceiver(mFinishCommandReceiver, new IntentFilter(ACTION_FINISH_JOB));
+        registerReceiver(mFinishCommandReceiver, new IntentFilter(ACTION_FINISH_JOB),
+                Context.RECEIVER_EXPORTED);
         return true;
     }
 
diff --git a/tests/cts/hostside/src/com/android/cts/net/HostsideRestrictBackgroundNetworkTests.java b/tests/cts/hostside/src/com/android/cts/net/HostsideRestrictBackgroundNetworkTests.java
index a95fc64..f633df4 100644
--- a/tests/cts/hostside/src/com/android/cts/net/HostsideRestrictBackgroundNetworkTests.java
+++ b/tests/cts/hostside/src/com/android/cts/net/HostsideRestrictBackgroundNetworkTests.java
@@ -330,6 +330,11 @@
                 "testNetworkAccess");
     }
 
+    public void testNetworkAccess_restrictedMode_withBatterySaver() throws Exception {
+        runDeviceTests(TEST_PKG, TEST_PKG + ".RestrictedModeTest",
+                "testNetworkAccess_withBatterySaver");
+    }
+
     /************************
      * Expedited job tests. *
      ************************/
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index fb720a7..de4f41b 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -29,6 +29,11 @@
 import static android.app.usage.NetworkStats.Bucket.STATE_FOREGROUND;
 import static android.app.usage.NetworkStats.Bucket.TAG_NONE;
 import static android.app.usage.NetworkStats.Bucket.UID_ALL;
+import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
+import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
+import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_XT;
+
+import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
 import android.app.AppOpsManager;
 import android.app.usage.NetworkStats;
@@ -40,7 +45,10 @@
 import android.net.NetworkCapabilities;
 import android.net.NetworkInfo;
 import android.net.NetworkRequest;
+import android.net.NetworkStatsCollection;
+import android.net.NetworkStatsHistory;
 import android.net.TrafficStats;
+import android.net.netstats.NetworkStatsDataMigrationUtils;
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.Process;
@@ -49,10 +57,13 @@
 import android.platform.test.annotations.AppModeFull;
 import android.telephony.TelephonyManager;
 import android.test.InstrumentationTestCase;
+import android.text.TextUtils;
 import android.util.Log;
 
 import com.android.compatibility.common.util.ShellIdentityUtils;
 import com.android.compatibility.common.util.SystemUtil;
+import com.android.modules.utils.build.SdkLevel;
+import com.android.testutils.DevSdkIgnoreRule;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -62,6 +73,10 @@
 import java.net.UnknownHostException;
 import java.text.MessageFormat;
 import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 public class NetworkStatsManagerTest extends InstrumentationTestCase {
     private static final String LOG_TAG = "NetworkStatsManagerTest";
@@ -825,6 +840,43 @@
             // storing files of >2MB in CTS.
 
             mNsm.unregisterUsageCallback(usageCallback);
+
+            // For T- devices, the registerUsageCallback invocation below will need a looper
+            // from the thread that calls into the API, which is not available in the test.
+            if (SdkLevel.isAtLeastT()) {
+                mNsm.registerUsageCallback(mNetworkInterfacesToTest[i].getNetworkType(),
+                        getSubscriberId(i), THRESHOLD_BYTES, usageCallback);
+                mNsm.unregisterUsageCallback(usageCallback);
+            }
+        }
+    }
+
+    @AppModeFull
+    @DevSdkIgnoreRule.IgnoreUpTo(SC_V2)
+    public void testDataMigrationUtils() throws Exception {
+        final List<String> prefixes = List.of(PREFIX_UID, PREFIX_XT, PREFIX_UID_TAG);
+        for (final String prefix : prefixes) {
+            final long duration = TextUtils.equals(PREFIX_XT, prefix) ? TimeUnit.HOURS.toMillis(1)
+                    : TimeUnit.HOURS.toMillis(2);
+
+            final NetworkStatsCollection collection =
+                    NetworkStatsDataMigrationUtils.readPlatformCollection(prefix, duration);
+
+            final long now = System.currentTimeMillis();
+            final Set<Map.Entry<NetworkStatsCollection.Key, NetworkStatsHistory>> entries =
+                    collection.getEntries().entrySet();
+            for (final Map.Entry<NetworkStatsCollection.Key, NetworkStatsHistory> entry : entries) {
+                for (final NetworkStatsHistory.Entry historyEntry : entry.getValue().getEntries()) {
+                    // Verify all value fields are reasonable.
+                    assertTrue(historyEntry.getBucketStart() <= now);
+                    assertTrue(historyEntry.getActiveTime() <= duration);
+                    assertTrue(historyEntry.getRxBytes() >= 0);
+                    assertTrue(historyEntry.getRxPackets() >= 0);
+                    assertTrue(historyEntry.getTxBytes() >= 0);
+                    assertTrue(historyEntry.getTxPackets() >= 0);
+                    assertTrue(historyEntry.getOperations() >= 0);
+                }
+            }
         }
     }
 
diff --git a/tests/deflake/Android.bp b/tests/deflake/Android.bp
index b3d0363..8205f1c 100644
--- a/tests/deflake/Android.bp
+++ b/tests/deflake/Android.bp
@@ -21,7 +21,7 @@
 
 // FrameworksNetDeflakeTest depends on FrameworksNetTests so it should be disabled
 // if FrameworksNetTests is disabled.
-enable_frameworks_net_deflake_test = false
+enable_frameworks_net_deflake_test = true
 // Placeholder
 // This is a placeholder comment to minimize merge conflicts, as enable_frameworks_net_deflake_test
 // may have different values depending on the branch
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index 901251c..988f21b 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -3,13 +3,17 @@
 //########################################################################
 package {
     // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "Android-Apache-2.0"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
 // Whether to enable the FrameworksNetTests. Set to false in the branches that might have older
 // frameworks/base since FrameworksNetTests includes the test for classes that are not in
 // connectivity module.
-enable_frameworks_net_tests = false
+enable_frameworks_net_tests = true
 // Placeholder
 // This is a placeholder comment to minimize merge conflicts, as enable_frameworks_net_tests
 // may have different values depending on the branch
diff --git a/tests/unit/java/android/net/NetworkStatsCollectionTest.java b/tests/unit/java/android/net/NetworkStatsCollectionTest.java
index 32c106d..0f02850 100644
--- a/tests/unit/java/android/net/NetworkStatsCollectionTest.java
+++ b/tests/unit/java/android/net/NetworkStatsCollectionTest.java
@@ -38,13 +38,11 @@
 import static org.junit.Assert.fail;
 
 import android.content.res.Resources;
-import android.net.NetworkStatsCollection.Key;
 import android.os.Process;
 import android.os.UserHandle;
 import android.telephony.SubscriptionPlan;
 import android.telephony.TelephonyManager;
 import android.text.format.DateUtils;
-import android.util.ArrayMap;
 import android.util.RecurrenceRule;
 
 import androidx.test.InstrumentationRegistry;
@@ -75,7 +73,6 @@
 import java.time.ZonedDateTime;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
 
 /**
  * Tests for {@link NetworkStatsCollection}.
@@ -534,52 +531,6 @@
         assertThrows(ArithmeticException.class, () -> multiplySafeByRational(30, 3, 0));
     }
 
-    @Test
-    public void testBuilder() {
-        final Map<Key, NetworkStatsHistory> expectedEntries = new ArrayMap<>();
-        final NetworkStats.Entry entry = new NetworkStats.Entry();
-        final NetworkIdentitySet ident = new NetworkIdentitySet();
-        final Key key1 = new Key(ident, 0, 0, 0);
-        final Key key2 = new Key(ident, 1, 0, 0);
-        final long bucketDuration = 10;
-
-        final NetworkStatsHistory.Entry entry1 = new NetworkStatsHistory.Entry(10, 10, 40,
-                4, 50, 5, 60);
-        final NetworkStatsHistory.Entry entry2 = new NetworkStatsHistory.Entry(30, 10, 3,
-                41, 7, 1, 0);
-
-        NetworkStatsHistory history1 = new NetworkStatsHistory.Builder(10, 5)
-                .addEntry(entry1)
-                .addEntry(entry2)
-                .build();
-
-        NetworkStatsHistory history2 = new NetworkStatsHistory(10, 5);
-
-        NetworkStatsCollection actualCollection = new NetworkStatsCollection.Builder(bucketDuration)
-                .addEntry(key1, history1)
-                .addEntry(key2, history2)
-                .build();
-
-        // The builder will omit any entry with empty history. Thus, history2
-        // is not expected in the result collection.
-        expectedEntries.put(key1, history1);
-
-        final Map<Key, NetworkStatsHistory> actualEntries = actualCollection.getEntries();
-
-        assertEquals(expectedEntries.size(), actualEntries.size());
-        for (Key expectedKey : expectedEntries.keySet()) {
-            final NetworkStatsHistory expectedHistory = expectedEntries.get(expectedKey);
-
-            final NetworkStatsHistory actualHistory = actualEntries.get(expectedKey);
-            assertNotNull(actualHistory);
-
-            assertEquals(expectedHistory.getEntries(), actualHistory.getEntries());
-
-            actualEntries.remove(expectedKey);
-        }
-        assertEquals(0, actualEntries.size());
-    }
-
     /**
      * Copy a {@link Resources#openRawResource(int)} into {@link File} for
      * testing purposes.
diff --git a/tests/unit/java/android/net/NetworkStatsHistoryTest.java b/tests/unit/java/android/net/NetworkStatsHistoryTest.java
index c170605..c5f8c00 100644
--- a/tests/unit/java/android/net/NetworkStatsHistoryTest.java
+++ b/tests/unit/java/android/net/NetworkStatsHistoryTest.java
@@ -56,7 +56,6 @@
 import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
-import java.util.List;
 import java.util.Random;
 
 @RunWith(DevSdkIgnoreRunner.class)
@@ -533,40 +532,6 @@
         assertEquals(512L + 4096L, stats.getTotalBytes());
     }
 
-    @Test
-    public void testBuilder() {
-        final NetworkStatsHistory.Entry entry1 = new NetworkStatsHistory.Entry(10, 30, 40,
-                4, 50, 5, 60);
-        final NetworkStatsHistory.Entry entry2 = new NetworkStatsHistory.Entry(30, 15, 3,
-                41, 7, 1, 0);
-        final NetworkStatsHistory.Entry entry3 = new NetworkStatsHistory.Entry(7, 301, 11,
-                14, 31, 2, 80);
-
-        final NetworkStatsHistory statsEmpty = new NetworkStatsHistory
-                .Builder(HOUR_IN_MILLIS, 10).build();
-        assertEquals(0, statsEmpty.getEntries().size());
-        assertEquals(HOUR_IN_MILLIS, statsEmpty.getBucketDuration());
-
-        NetworkStatsHistory statsSingle = new NetworkStatsHistory
-                .Builder(HOUR_IN_MILLIS, 8)
-                .addEntry(entry1)
-                .build();
-        assertEquals(1, statsSingle.getEntries().size());
-        assertEquals(HOUR_IN_MILLIS, statsSingle.getBucketDuration());
-        assertEquals(entry1, statsSingle.getEntries().get(0));
-
-        NetworkStatsHistory statsMultiple = new NetworkStatsHistory
-                .Builder(SECOND_IN_MILLIS, 0)
-                .addEntry(entry1).addEntry(entry2).addEntry(entry3)
-                .build();
-        final List<NetworkStatsHistory.Entry> entries = statsMultiple.getEntries();
-        assertEquals(3, entries.size());
-        assertEquals(SECOND_IN_MILLIS, statsMultiple.getBucketDuration());
-        assertEquals(entry1, entries.get(0));
-        assertEquals(entry2, entries.get(1));
-        assertEquals(entry3, entries.get(2));
-    }
-
     private static void assertIndexBeforeAfter(
             NetworkStatsHistory stats, int before, int after, long time) {
         assertEquals("unexpected before", before, stats.getIndexBefore(time));
diff --git a/tests/unit/java/android/net/NetworkTemplateTest.kt b/tests/unit/java/android/net/NetworkTemplateTest.kt
index 453612f..abd1825 100644
--- a/tests/unit/java/android/net/NetworkTemplateTest.kt
+++ b/tests/unit/java/android/net/NetworkTemplateTest.kt
@@ -29,12 +29,8 @@
 import android.net.NetworkStats.METERED_NO
 import android.net.NetworkStats.METERED_YES
 import android.net.NetworkStats.ROAMING_ALL
-import android.net.NetworkTemplate.MATCH_BLUETOOTH
-import android.net.NetworkTemplate.MATCH_CARRIER
-import android.net.NetworkTemplate.MATCH_ETHERNET
 import android.net.NetworkTemplate.MATCH_MOBILE
 import android.net.NetworkTemplate.MATCH_MOBILE_WILDCARD
-import android.net.NetworkTemplate.MATCH_PROXY
 import android.net.NetworkTemplate.MATCH_WIFI
 import android.net.NetworkTemplate.MATCH_WIFI_WILDCARD
 import android.net.NetworkTemplate.NETWORK_TYPE_ALL
@@ -52,11 +48,9 @@
 import android.net.wifi.WifiInfo
 import android.os.Build
 import android.telephony.TelephonyManager
-import com.android.net.module.util.NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_ALL
 import com.android.net.module.util.NetworkStatsUtils.SUBSCRIBER_ID_MATCH_RULE_EXACT
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
-import com.android.testutils.SC_V2
 import com.android.testutils.assertParcelSane
 import org.junit.Before
 import org.junit.Test
@@ -65,7 +59,6 @@
 import org.mockito.Mockito.`when`
 import org.mockito.MockitoAnnotations
 import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
 import kotlin.test.assertNotEquals
 import kotlin.test.assertTrue
@@ -555,140 +548,4 @@
             it.assertMatches(identMobileImsi3)
         }
     }
-
-    @DevSdkIgnoreRule.IgnoreUpTo(SC_V2) // TODO: Use to Build.VERSION_CODES.SC_V2 when available
-    @Test
-    fun testBuilderMatchRules() {
-        // Verify unknown match rules cannot construct templates.
-        listOf(Integer.MIN_VALUE, -1, Integer.MAX_VALUE).forEach {
-            assertFailsWith<IllegalArgumentException> {
-                NetworkTemplate.Builder(it).build()
-            }
-        }
-
-        // Verify hidden match rules cannot construct templates.
-        listOf(MATCH_WIFI_WILDCARD, MATCH_MOBILE_WILDCARD, MATCH_PROXY).forEach {
-            assertFailsWith<IllegalArgumentException> {
-                NetworkTemplate.Builder(it).build()
-            }
-        }
-
-        // Verify template which matches metered cellular and carrier networks with
-        // the given IMSI. See buildTemplateMobileAll and buildTemplateCarrierMetered.
-        listOf(MATCH_MOBILE, MATCH_CARRIER).forEach { matchRule ->
-            NetworkTemplate.Builder(matchRule).setSubscriberIds(setOf(TEST_IMSI1))
-                    .setMeteredness(METERED_YES).build().let {
-                        val expectedTemplate = NetworkTemplate(matchRule, TEST_IMSI1,
-                                arrayOf(TEST_IMSI1), arrayOf<String>(), METERED_YES,
-                                ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
-                                OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_EXACT)
-                        assertEquals(expectedTemplate, it)
-                    }
-        }
-
-        // Verify carrier template cannot be created without IMSI.
-        assertFailsWith<IllegalArgumentException> {
-            NetworkTemplate.Builder(MATCH_CARRIER).build()
-        }
-
-        // Verify template which matches metered cellular networks,
-        // regardless of IMSI. See buildTemplateMobileWildcard.
-        NetworkTemplate.Builder(MATCH_MOBILE).setMeteredness(METERED_YES).build().let {
-            val expectedTemplate = NetworkTemplate(MATCH_MOBILE_WILDCARD, null /*subscriberId*/,
-                    null /*subscriberIds*/, arrayOf<String>(),
-                    METERED_YES, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
-                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
-            assertEquals(expectedTemplate, it)
-        }
-
-        // Verify template which matches metered cellular networks and ratType.
-        // See NetworkTemplate#buildTemplateMobileWithRatType.
-        NetworkTemplate.Builder(MATCH_MOBILE).setSubscriberIds(setOf(TEST_IMSI1))
-                .setMeteredness(METERED_YES).setRatType(TelephonyManager.NETWORK_TYPE_UMTS)
-                .build().let {
-                    val expectedTemplate = NetworkTemplate(MATCH_MOBILE, TEST_IMSI1,
-                            arrayOf(TEST_IMSI1), arrayOf<String>(), METERED_YES,
-                            ROAMING_ALL, DEFAULT_NETWORK_ALL, TelephonyManager.NETWORK_TYPE_UMTS,
-                            OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_EXACT)
-                    assertEquals(expectedTemplate, it)
-                }
-
-        // Verify template which matches all wifi networks,
-        // regardless of Wifi Network Key. See buildTemplateWifiWildcard and buildTemplateWifi.
-        NetworkTemplate.Builder(MATCH_WIFI).build().let {
-            val expectedTemplate = NetworkTemplate(MATCH_WIFI_WILDCARD, null /*subscriberId*/,
-                    null /*subscriberIds*/, arrayOf<String>(),
-                    METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
-                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
-            assertEquals(expectedTemplate, it)
-        }
-
-        // Verify template which matches wifi networks with the given Wifi Network Key.
-        // See buildTemplateWifi(wifiNetworkKey).
-        NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(setOf(TEST_WIFI_KEY1)).build().let {
-            val expectedTemplate = NetworkTemplate(MATCH_WIFI, null /*subscriberId*/,
-                    null /*subscriberIds*/, arrayOf(TEST_WIFI_KEY1),
-                    METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
-                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
-            assertEquals(expectedTemplate, it)
-        }
-
-        // Verify template which matches all wifi networks with the
-        // given Wifi Network Key, and IMSI. See buildTemplateWifi(wifiNetworkKey, subscriberId).
-        NetworkTemplate.Builder(MATCH_WIFI).setSubscriberIds(setOf(TEST_IMSI1))
-                .setWifiNetworkKeys(setOf(TEST_WIFI_KEY1)).build().let {
-                    val expectedTemplate = NetworkTemplate(MATCH_WIFI, TEST_IMSI1,
-                            arrayOf(TEST_IMSI1), arrayOf(TEST_WIFI_KEY1),
-                            METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
-                            OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_EXACT)
-                    assertEquals(expectedTemplate, it)
-                }
-
-        // Verify template which matches ethernet and bluetooth networks.
-        // See buildTemplateEthernet and buildTemplateBluetooth.
-        listOf(MATCH_ETHERNET, MATCH_BLUETOOTH).forEach { matchRule ->
-            NetworkTemplate.Builder(matchRule).build().let {
-                val expectedTemplate = NetworkTemplate(matchRule, null /*subscriberId*/,
-                        null /*subscriberIds*/, arrayOf<String>(),
-                        METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
-                        OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
-                assertEquals(expectedTemplate, it)
-            }
-        }
-    }
-
-    @DevSdkIgnoreRule.IgnoreUpTo(SC_V2) // TODO: Use to Build.VERSION_CODES.SC_V2 when available
-    @Test
-    fun testBuilderWifiNetworkKeys() {
-        // Verify template builder which generates same template with the given different
-        // sequence keys.
-        NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(
-                setOf(TEST_WIFI_KEY1, TEST_WIFI_KEY2)).build().let {
-            val expectedTemplate = NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(
-                    setOf(TEST_WIFI_KEY2, TEST_WIFI_KEY1)).build()
-            assertEquals(expectedTemplate, it)
-        }
-
-        // Verify template which matches non-wifi networks with the given key is invalid.
-        listOf(MATCH_MOBILE, MATCH_CARRIER, MATCH_ETHERNET, MATCH_BLUETOOTH, -1,
-                Integer.MAX_VALUE).forEach { matchRule ->
-            assertFailsWith<IllegalArgumentException> {
-                NetworkTemplate.Builder(matchRule).setWifiNetworkKeys(setOf(TEST_WIFI_KEY1)).build()
-            }
-        }
-
-        // Verify template which matches wifi networks with the given null key is invalid.
-        assertFailsWith<IllegalArgumentException> {
-            NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(setOf(null)).build()
-        }
-
-        // Verify template which matches wifi wildcard with the given empty key set.
-        NetworkTemplate.Builder(MATCH_WIFI).setWifiNetworkKeys(setOf<String>()).build().let {
-            val expectedTemplate = NetworkTemplate(MATCH_WIFI_WILDCARD, null /*subscriberId*/,
-                    arrayOf<String>() /*subscriberIds*/, arrayOf<String>(),
-                    METERED_ALL, ROAMING_ALL, DEFAULT_NETWORK_ALL, NETWORK_TYPE_ALL,
-                    OEM_MANAGED_ALL, SUBSCRIBER_ID_MATCH_RULE_ALL)
-            assertEquals(expectedTemplate, it)
-        }
-    }
 }
diff --git a/tests/unit/java/com/android/server/NativeDaemonConnectorTest.java b/tests/unit/java/com/android/server/NativeDaemonConnectorTest.java
new file mode 100644
index 0000000..e2253a2
--- /dev/null
+++ b/tests/unit/java/com/android/server/NativeDaemonConnectorTest.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server;
+
+import static com.android.server.NativeDaemonConnector.appendEscaped;
+import static com.android.server.NativeDaemonConnector.makeCommand;
+
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import com.android.server.NativeDaemonConnector.SensitiveArg;
+
+/**
+ * Tests for {@link NativeDaemonConnector}.
+ */
+@MediumTest
+public class NativeDaemonConnectorTest extends AndroidTestCase {
+    private static final String TAG = "NativeDaemonConnectorTest";
+
+    public void testArgumentNormal() throws Exception {
+        final StringBuilder builder = new StringBuilder();
+
+        builder.setLength(0);
+        appendEscaped(builder, "");
+        assertEquals("", builder.toString());
+
+        builder.setLength(0);
+        appendEscaped(builder, "foo");
+        assertEquals("foo", builder.toString());
+
+        builder.setLength(0);
+        appendEscaped(builder, "foo\"bar");
+        assertEquals("foo\\\"bar", builder.toString());
+
+        builder.setLength(0);
+        appendEscaped(builder, "foo\\bar\\\"baz");
+        assertEquals("foo\\\\bar\\\\\\\"baz", builder.toString());
+    }
+
+    public void testArgumentWithSpaces() throws Exception {
+        final StringBuilder builder = new StringBuilder();
+
+        builder.setLength(0);
+        appendEscaped(builder, "foo bar");
+        assertEquals("\"foo bar\"", builder.toString());
+
+        builder.setLength(0);
+        appendEscaped(builder, "foo\"bar\\baz foo");
+        assertEquals("\"foo\\\"bar\\\\baz foo\"", builder.toString());
+    }
+
+    public void testArgumentWithUtf() throws Exception {
+        final StringBuilder builder = new StringBuilder();
+
+        builder.setLength(0);
+        appendEscaped(builder, "caf\u00E9 c\u00F6ffee");
+        assertEquals("\"caf\u00E9 c\u00F6ffee\"", builder.toString());
+    }
+
+    public void testSensitiveArgs() throws Exception {
+        final StringBuilder rawBuilder = new StringBuilder();
+        final StringBuilder logBuilder = new StringBuilder();
+
+        rawBuilder.setLength(0);
+        logBuilder.setLength(0);
+        makeCommand(rawBuilder, logBuilder, 1, "foo", "bar", "baz");
+        assertEquals("1 foo bar baz\0", rawBuilder.toString());
+        assertEquals("1 foo bar baz", logBuilder.toString());
+
+        rawBuilder.setLength(0);
+        logBuilder.setLength(0);
+        makeCommand(rawBuilder, logBuilder, 1, "foo", new SensitiveArg("bar"), "baz");
+        assertEquals("1 foo bar baz\0", rawBuilder.toString());
+        assertEquals("1 foo [scrubbed] baz", logBuilder.toString());
+
+        rawBuilder.setLength(0);
+        logBuilder.setLength(0);
+        makeCommand(rawBuilder, logBuilder, 1, "foo", new SensitiveArg("foo bar"), "baz baz",
+                new SensitiveArg("wat"));
+        assertEquals("1 foo \"foo bar\" \"baz baz\" wat\0", rawBuilder.toString());
+        assertEquals("1 foo [scrubbed] \"baz baz\" [scrubbed]", logBuilder.toString());
+    }
+}
diff --git a/tests/unit/java/com/android/server/net/IpConfigStoreTest.java b/tests/unit/java/com/android/server/net/IpConfigStoreTest.java
new file mode 100644
index 0000000..e9a5309
--- /dev/null
+++ b/tests/unit/java/com/android/server/net/IpConfigStoreTest.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2018 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.fail;
+
+import android.net.InetAddresses;
+import android.net.IpConfiguration;
+import android.net.IpConfiguration.IpAssignment;
+import android.net.IpConfiguration.ProxySettings;
+import android.net.LinkAddress;
+import android.net.ProxyInfo;
+import android.net.StaticIpConfiguration;
+import android.util.ArrayMap;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+/**
+ * Unit tests for {@link IpConfigStore}
+ */
+@RunWith(AndroidJUnit4.class)
+public class IpConfigStoreTest {
+    private static final int KEY_CONFIG = 17;
+    private static final String IFACE_1 = "eth0";
+    private static final String IFACE_2 = "eth1";
+    private static final String IP_ADDR_1 = "192.168.1.10/24";
+    private static final String IP_ADDR_2 = "192.168.1.20/24";
+    private static final String DNS_IP_ADDR_1 = "1.2.3.4";
+    private static final String DNS_IP_ADDR_2 = "5.6.7.8";
+
+    @Test
+    public void backwardCompatibility2to3() throws IOException {
+        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
+        DataOutputStream outputStream = new DataOutputStream(byteStream);
+
+        final IpConfiguration expectedConfig =
+                newIpConfiguration(IpAssignment.DHCP, ProxySettings.NONE, null, null);
+
+        // Emulate writing to old format.
+        writeDhcpConfigV2(outputStream, KEY_CONFIG, expectedConfig);
+
+        InputStream in = new ByteArrayInputStream(byteStream.toByteArray());
+        ArrayMap<String, IpConfiguration> configurations = IpConfigStore.readIpConfigurations(in);
+
+        assertNotNull(configurations);
+        assertEquals(1, configurations.size());
+        IpConfiguration actualConfig = configurations.get(String.valueOf(KEY_CONFIG));
+        assertNotNull(actualConfig);
+        assertEquals(expectedConfig, actualConfig);
+    }
+
+    @Test
+    public void staticIpMultiNetworks() throws Exception {
+        final ArrayList<InetAddress> dnsServers = new ArrayList<>();
+        dnsServers.add(InetAddresses.parseNumericAddress(DNS_IP_ADDR_1));
+        dnsServers.add(InetAddresses.parseNumericAddress(DNS_IP_ADDR_2));
+        final StaticIpConfiguration staticIpConfiguration1 = new StaticIpConfiguration.Builder()
+                .setIpAddress(new LinkAddress(IP_ADDR_1))
+                .setDnsServers(dnsServers).build();
+        final StaticIpConfiguration staticIpConfiguration2 = new StaticIpConfiguration.Builder()
+                .setIpAddress(new LinkAddress(IP_ADDR_2))
+                .setDnsServers(dnsServers).build();
+
+        ProxyInfo proxyInfo =
+                ProxyInfo.buildDirectProxy("10.10.10.10", 88, Arrays.asList("host1", "host2"));
+
+        IpConfiguration expectedConfig1 = newIpConfiguration(IpAssignment.STATIC,
+                ProxySettings.STATIC, staticIpConfiguration1, proxyInfo);
+        IpConfiguration expectedConfig2 = newIpConfiguration(IpAssignment.STATIC,
+                ProxySettings.STATIC, staticIpConfiguration2, proxyInfo);
+
+        ArrayMap<String, IpConfiguration> expectedNetworks = new ArrayMap<>();
+        expectedNetworks.put(IFACE_1, expectedConfig1);
+        expectedNetworks.put(IFACE_2, expectedConfig2);
+
+        MockedDelayedDiskWrite writer = new MockedDelayedDiskWrite();
+        IpConfigStore store = new IpConfigStore(writer);
+        store.writeIpConfigurations("file/path/not/used/", expectedNetworks);
+
+        InputStream in = new ByteArrayInputStream(writer.mByteStream.toByteArray());
+        ArrayMap<String, IpConfiguration> actualNetworks = IpConfigStore.readIpConfigurations(in);
+        assertNotNull(actualNetworks);
+        assertEquals(2, actualNetworks.size());
+        assertEquals(expectedNetworks.get(IFACE_1), actualNetworks.get(IFACE_1));
+        assertEquals(expectedNetworks.get(IFACE_2), actualNetworks.get(IFACE_2));
+    }
+
+    private IpConfiguration newIpConfiguration(IpAssignment ipAssignment,
+            ProxySettings proxySettings, StaticIpConfiguration staticIpConfig, ProxyInfo info) {
+        final IpConfiguration config = new IpConfiguration();
+        config.setIpAssignment(ipAssignment);
+        config.setProxySettings(proxySettings);
+        config.setStaticIpConfiguration(staticIpConfig);
+        config.setHttpProxy(info);
+        return config;
+    }
+
+    // This is simplified snapshot of code that was used to store values in V2 format (key as int).
+    private static void writeDhcpConfigV2(DataOutputStream out, int configKey,
+            IpConfiguration config) throws IOException {
+        out.writeInt(2);  // VERSION 2
+        switch (config.getIpAssignment()) {
+            case DHCP:
+                out.writeUTF("ipAssignment");
+                out.writeUTF(config.getIpAssignment().toString());
+                break;
+            default:
+                fail("Not supported in test environment");
+        }
+
+        out.writeUTF("id");
+        out.writeInt(configKey);
+        out.writeUTF("eos");
+    }
+
+    /** Synchronously writes into given byte steam */
+    private static class MockedDelayedDiskWrite extends DelayedDiskWrite {
+        final ByteArrayOutputStream mByteStream = new ByteArrayOutputStream();
+
+        @Override
+        public void write(String filePath, Writer w) {
+            DataOutputStream outputStream = new DataOutputStream(mByteStream);
+
+            try {
+                w.onWriteCalled(outputStream);
+            } catch (IOException e) {
+                fail();
+            }
+        }
+    }
+}