Enforce permission check in NearbyService

Test: -m confirmed logs in discovery and unit tests
Bug: 229338477
Ignore-AOSP-First: nearby_not_in_aosp_yet
Change-Id: I801cf1c8867cb45d7de08ba96bd9f7354f9053b8
diff --git a/nearby/framework/java/android/nearby/INearbyManager.aidl b/nearby/framework/java/android/nearby/INearbyManager.aidl
index 62e109e..3fd5ecc 100644
--- a/nearby/framework/java/android/nearby/INearbyManager.aidl
+++ b/nearby/framework/java/android/nearby/INearbyManager.aidl
@@ -28,12 +28,13 @@
  */
 interface INearbyManager {
 
-    int registerScanListener(in ScanRequest scanRequest, in IScanListener listener);
+    int registerScanListener(in ScanRequest scanRequest, in IScanListener listener,
+            String packageName, @nullable String attributionTag);
 
     void unregisterScanListener(in IScanListener listener);
 
     void startBroadcast(in BroadcastRequestParcelable broadcastRequest,
-            in IBroadcastListener callback);
+            in IBroadcastListener callback, String packageName, @nullable String attributionTag);
 
     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
index 54033aa..3e3b107 100644
--- a/nearby/framework/java/android/nearby/IScanListener.aidl
+++ b/nearby/framework/java/android/nearby/IScanListener.aidl
@@ -32,4 +32,7 @@
 
         /** Reports a {@link NearbyDevice} is no longer within range. */
         void onLost(in NearbyDeviceParcelable nearbyDeviceParcelable);
+
+        /** Reports when there is an error during scanning. */
+        void onError();
 }
diff --git a/nearby/framework/java/android/nearby/NearbyFrameworkInitializer.java b/nearby/framework/java/android/nearby/NearbyFrameworkInitializer.java
index 3780fbb..b732d67 100644
--- a/nearby/framework/java/android/nearby/NearbyFrameworkInitializer.java
+++ b/nearby/framework/java/android/nearby/NearbyFrameworkInitializer.java
@@ -43,7 +43,7 @@
                 NearbyManager.class,
                 (context, serviceBinder) -> {
                     INearbyManager service = INearbyManager.Stub.asInterface(serviceBinder);
-                    return new NearbyManager(service);
+                    return new NearbyManager(context, service);
                 }
         );
     }
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index 3dd08da..b7479ac 100644
--- a/nearby/framework/java/android/nearby/NearbyManager.java
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -28,6 +28,7 @@
 import android.content.Context;
 import android.os.RemoteException;
 import android.provider.Settings;
+import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.util.Preconditions;
@@ -85,6 +86,7 @@
     private static final WeakHashMap<BroadcastCallback, WeakReference<BroadcastListenerTransport>>
             sBroadcastListeners = new WeakHashMap<>();
 
+    private final Context mContext;
     private final INearbyManager mService;
 
     /**
@@ -92,7 +94,10 @@
      *
      * @param service the service object
      */
-    NearbyManager(@NonNull INearbyManager service) {
+    NearbyManager(@NonNull Context context, @NonNull INearbyManager service) {
+        Objects.requireNonNull(context);
+        Objects.requireNonNull(service);
+        mContext = context;
         mService = service;
     }
 
@@ -143,7 +148,8 @@
                     Preconditions.checkState(transport.isRegistered());
                     transport.setExecutor(executor);
                 }
-                @ScanStatus int status = mService.registerScanListener(scanRequest, transport);
+                @ScanStatus int status = mService.registerScanListener(scanRequest, transport,
+                        mContext.getPackageName(), mContext.getAttributionTag());
                 if (status != ScanStatus.SUCCESS) {
                     return status;
                 }
@@ -208,8 +214,8 @@
                     Preconditions.checkState(transport.isRegistered());
                     transport.setExecutor(executor);
                 }
-                mService.startBroadcast(new BroadcastRequestParcelable(broadcastRequest),
-                        transport);
+                mService.startBroadcast(new BroadcastRequestParcelable(broadcastRequest), transport,
+                        mContext.getPackageName(), mContext.getAttributionTag());
                 sBroadcastListeners.put(callback, new WeakReference<>(transport));
             }
         } catch (RemoteException e) {
@@ -330,6 +336,15 @@
                 }
             });
         }
+
+        @Override
+        public void onError() {
+            mExecutor.execute(() -> {
+                if (mScanCallback != null) {
+                    Log.e("NearbyManager", "onError: There is an error in scan.");
+                }
+            });
+        }
     }
 
     private static class BroadcastListenerTransport extends IBroadcastListener.Stub {
diff --git a/nearby/service/java/com/android/server/nearby/NearbyService.java b/nearby/service/java/com/android/server/nearby/NearbyService.java
index e3e5b5d..2dee835 100644
--- a/nearby/service/java/com/android/server/nearby/NearbyService.java
+++ b/nearby/service/java/com/android/server/nearby/NearbyService.java
@@ -17,9 +17,12 @@
 package com.android.server.nearby;
 
 import static com.android.server.SystemService.PHASE_BOOT_COMPLETED;
+import static com.android.server.SystemService.PHASE_SYSTEM_SERVICES_READY;
 import static com.android.server.SystemService.PHASE_THIRD_PARTY_APPS_CAN_START;
 
 import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.app.AppOpsManager;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothManager;
 import android.content.BroadcastReceiver;
@@ -35,6 +38,7 @@
 import android.nearby.ScanRequest;
 import android.util.Log;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.nearby.common.locator.LocatorContextWrapper;
 import com.android.server.nearby.fastpair.FastPairManager;
 import com.android.server.nearby.injector.ContextHubManagerAdapter;
@@ -43,13 +47,16 @@
 import com.android.server.nearby.provider.BroadcastProviderManager;
 import com.android.server.nearby.provider.DiscoveryProviderManager;
 import com.android.server.nearby.provider.FastPairDataProvider;
+import com.android.server.nearby.util.identity.CallerIdentity;
+import com.android.server.nearby.util.permissions.BroadcastPermissions;
+import com.android.server.nearby.util.permissions.DiscoveryPermissions;
 
 /** 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 Injector mInjector;
     private final FastPairManager mFastPairManager;
     private final PresenceManager mPresenceManager;
     private final BroadcastReceiver mBluetoothReceiver =
@@ -60,11 +67,11 @@
                             intent.getIntExtra(
                                     BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR);
                     if (state == BluetoothAdapter.STATE_ON) {
-                        if (mSystemInjector != null) {
+                        if (mInjector != null && mInjector instanceof SystemInjector) {
                             // 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();
+                            ((SystemInjector) mInjector).initializeBluetoothAdapter();
                         }
                     }
                 }
@@ -74,18 +81,29 @@
 
     public NearbyService(Context context) {
         mContext = context;
-        mSystemInjector = new SystemInjector(context);
-        mProviderManager = new DiscoveryProviderManager(context, mSystemInjector);
-        mBroadcastProviderManager = new BroadcastProviderManager(context, mSystemInjector);
+        mInjector = new SystemInjector(context);
+        mProviderManager = new DiscoveryProviderManager(context, mInjector);
+        mBroadcastProviderManager = new BroadcastProviderManager(context, mInjector);
         final LocatorContextWrapper lcw = new LocatorContextWrapper(context, null);
         mFastPairManager = new FastPairManager(lcw);
         mPresenceManager = new PresenceManager(lcw);
     }
 
+    @VisibleForTesting
+    void setInjector(Injector injector) {
+        this.mInjector = injector;
+    }
+
     @Override
     @NearbyManager.ScanStatus
-    public int registerScanListener(ScanRequest scanRequest, IScanListener listener) {
-        if (mProviderManager.registerScanListener(scanRequest, listener)) {
+    public int registerScanListener(ScanRequest scanRequest, IScanListener listener,
+            String packageName, @Nullable String attributionTag) {
+        // Permissions check
+        enforceBluetoothPrivilegedPermission(mContext);
+        CallerIdentity identity = CallerIdentity.fromBinder(mContext, packageName, attributionTag);
+        DiscoveryPermissions.enforceDiscoveryPermission(mContext, identity);
+
+        if (mProviderManager.registerScanListener(scanRequest, listener, identity)) {
             return NearbyManager.ScanStatus.SUCCESS;
         }
         return NearbyManager.ScanStatus.ERROR;
@@ -98,10 +116,12 @@
 
     @Override
     public void startBroadcast(BroadcastRequestParcelable broadcastRequestParcelable,
-            IBroadcastListener listener) {
+            IBroadcastListener listener, String packageName, @Nullable String attributionTag) {
+        enforceBluetoothPrivilegedPermission(mContext);
+        BroadcastPermissions.enforceBroadcastPermission(
+                mContext, CallerIdentity.fromBinder(mContext, packageName, attributionTag));
         mBroadcastProviderManager.startBroadcast(
-                broadcastRequestParcelable.getBroadcastRequest(),
-                listener);
+                broadcastRequestParcelable.getBroadcastRequest(), listener);
     }
 
     @Override
@@ -116,28 +136,48 @@
      */
     public void onBootPhase(int phase) {
         switch (phase) {
+            case PHASE_SYSTEM_SERVICES_READY:
+                if (mInjector instanceof SystemInjector) {
+                    ((SystemInjector) mInjector).initializeAppOpsManager();
+                }
+                break;
             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();
+                if (mInjector instanceof SystemInjector) {
+                    // The nearby service must be functioning after this boot phase.
+                    ((SystemInjector) mInjector).initializeBluetoothAdapter();
+                    // Initialize ContextManager for CHRE scan.
+                    ((SystemInjector) mInjector).initializeContextHubManagerAdapter();
+                }
                 mContext.registerReceiver(
                         mBluetoothReceiver,
                         new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
                 mFastPairManager.initiate();
-                // Initialize ContextManager for CHRE scan.
-                mSystemInjector.initializeContextHubManagerAdapter();
                 mPresenceManager.initiate();
                 break;
         }
     }
 
+    /**
+     * If the calling process of has not been granted
+     * {@link android.Manifest.permission.BLUETOOTH_PRIVILEGED} permission,
+     * throw a {@link SecurityException}.
+     */
+    @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED)
+    private static void enforceBluetoothPrivilegedPermission(Context context) {
+        context.enforceCallingOrSelfPermission(
+                android.Manifest.permission.BLUETOOTH_PRIVILEGED,
+                "Need BLUETOOTH PRIVILEGED permission");
+    }
+
     private static final class SystemInjector implements Injector {
         private final Context mContext;
         @Nullable private BluetoothAdapter mBluetoothAdapter;
         @Nullable private ContextHubManagerAdapter mContextHubManagerAdapter;
+        @Nullable private AppOpsManager mAppOpsManager;
 
         SystemInjector(Context context) {
             mContext = context;
@@ -155,6 +195,12 @@
             return mContextHubManagerAdapter;
         }
 
+        @Override
+        @Nullable
+        public AppOpsManager getAppOpsManager() {
+            return mAppOpsManager;
+        }
+
         synchronized void initializeBluetoothAdapter() {
             if (mBluetoothAdapter != null) {
                 return;
@@ -176,5 +222,12 @@
             }
             mContextHubManagerAdapter = new ContextHubManagerAdapter(manager);
         }
+
+        synchronized void initializeAppOpsManager() {
+            if (mAppOpsManager != null) {
+                return;
+            }
+            mAppOpsManager = mContext.getSystemService(AppOpsManager.class);
+        }
     }
 }
diff --git a/nearby/service/java/com/android/server/nearby/injector/Injector.java b/nearby/service/java/com/android/server/nearby/injector/Injector.java
index f990dc9..57784a9 100644
--- a/nearby/service/java/com/android/server/nearby/injector/Injector.java
+++ b/nearby/service/java/com/android/server/nearby/injector/Injector.java
@@ -16,6 +16,7 @@
 
 package com.android.server.nearby.injector;
 
+import android.app.AppOpsManager;
 import android.bluetooth.BluetoothAdapter;
 
 /**
@@ -29,4 +30,7 @@
 
     /** Get the ContextHubManagerAdapter for ChreDiscoveryProvider to scan. */
     ContextHubManagerAdapter getContextHubManagerAdapter();
+
+    /** Get the AppOpsManager to control access. */
+    AppOpsManager getAppOpsManager();
 }
diff --git a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java
index 53d61c2..bdeab51 100644
--- a/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java
+++ b/nearby/service/java/com/android/server/nearby/provider/DiscoveryProviderManager.java
@@ -21,6 +21,7 @@
 import static com.android.server.nearby.NearbyService.TAG;
 
 import android.annotation.Nullable;
+import android.app.AppOpsManager;
 import android.content.Context;
 import android.nearby.IScanListener;
 import android.nearby.NearbyDeviceParcelable;
@@ -35,6 +36,8 @@
 import com.android.server.nearby.injector.Injector;
 import com.android.server.nearby.metrics.NearbyMetrics;
 import com.android.server.nearby.presence.PresenceDiscoveryResult;
+import com.android.server.nearby.util.identity.CallerIdentity;
+import com.android.server.nearby.util.permissions.DiscoveryPermissions;
 
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -53,6 +56,7 @@
     private final BleDiscoveryProvider mBleDiscoveryProvider;
     @Nullable private final ChreDiscoveryProvider mChreDiscoveryProvider;
     private @ScanRequest.ScanMode int mScanMode;
+    private final Injector mInjector;
 
     @GuardedBy("mLock")
     private Map<IBinder, ScanListenerRecord> mScanTypeScanListenerRecordMap;
@@ -60,12 +64,26 @@
     @Override
     public void onNearbyDeviceDiscovered(NearbyDeviceParcelable nearbyDevice) {
         synchronized (mLock) {
+            AppOpsManager appOpsManager = Objects.requireNonNull(mInjector.getAppOpsManager());
             for (IBinder listenerBinder : mScanTypeScanListenerRecordMap.keySet()) {
                 ScanListenerRecord record = mScanTypeScanListenerRecordMap.get(listenerBinder);
                 if (record == null) {
                     Log.w(TAG, "DiscoveryProviderManager cannot find the scan record.");
                     continue;
                 }
+                CallerIdentity callerIdentity = record.getCallerIdentity();
+                if (!DiscoveryPermissions.noteDiscoveryResultDelivery(
+                        appOpsManager, callerIdentity)) {
+                    Log.w(TAG, "[DiscoveryProviderManager] scan permission revoked "
+                            + "- not forwarding results");
+                    try {
+                        record.getScanListener().onError();
+                    } catch (RemoteException e) {
+                        Log.w(TAG, "DiscoveryProviderManager failed to report error.", e);
+                    }
+                    return;
+                }
+
                 if (nearbyDevice.getScanType() == SCAN_TYPE_NEARBY_PRESENCE) {
                     List<ScanFilter> presenceFilters =
                             record.getScanRequest().getScanFilters().stream()
@@ -103,13 +121,14 @@
                 new ChreDiscoveryProvider(
                         mContext, new ChreCommunication(injector, executor), executor);
         mScanTypeScanListenerRecordMap = new HashMap<>();
+        mInjector = injector;
     }
 
     /**
      * Registers the listener in the manager and starts scan according to the requested scan mode.
      */
-    public boolean registerScanListener(ScanRequest scanRequest, IScanListener listener) {
-        Log.i(TAG, "DiscoveryProviderManager registerScanListener");
+    public boolean registerScanListener(ScanRequest scanRequest, IScanListener listener,
+            CallerIdentity callerIdentity) {
         synchronized (mLock) {
             IBinder listenerBinder = listener.asBinder();
             if (mScanTypeScanListenerRecordMap.containsKey(listener.asBinder())) {
@@ -120,7 +139,8 @@
                     return true;
                 }
             }
-            ScanListenerRecord scanListenerRecord = new ScanListenerRecord(scanRequest, listener);
+            ScanListenerRecord scanListenerRecord =
+                    new ScanListenerRecord(scanRequest, listener, callerIdentity);
             mScanTypeScanListenerRecordMap.put(listenerBinder, scanListenerRecord);
 
             if (!startProviders(scanRequest)) {
@@ -273,9 +293,13 @@
 
         private final IScanListener mScanListener;
 
-        ScanListenerRecord(ScanRequest scanRequest, IScanListener iScanListener) {
+        private final CallerIdentity mCallerIdentity;
+
+        ScanListenerRecord(ScanRequest scanRequest, IScanListener iScanListener,
+                CallerIdentity callerIdentity) {
             mScanListener = iScanListener;
             mScanRequest = scanRequest;
+            mCallerIdentity = callerIdentity;
         }
 
         IScanListener getScanListener() {
@@ -286,6 +310,10 @@
             return mScanRequest;
         }
 
+        CallerIdentity getCallerIdentity() {
+            return mCallerIdentity;
+        }
+
         @Override
         public boolean equals(Object other) {
             if (other instanceof ScanListenerRecord) {
diff --git a/nearby/service/java/com/android/server/nearby/util/identity/CallerIdentity.java b/nearby/service/java/com/android/server/nearby/util/identity/CallerIdentity.java
new file mode 100644
index 0000000..b5c80b9
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/identity/CallerIdentity.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.util.identity;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.os.Binder;
+import android.os.Process;
+
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Objects;
+
+/**
+ * Identifying information on a caller.
+ *
+ * @hide
+ */
+public final class CallerIdentity {
+
+    /**
+     * Creates a CallerIdentity from the current binder identity, using the given package, feature
+     * id, and listener id. The package will be checked to enforce it belongs to the calling uid,
+     * and a security exception will be thrown if it is invalid.
+     */
+    public static CallerIdentity fromBinder(Context context, String packageName,
+            @Nullable String attributionTag) {
+        int uid = Binder.getCallingUid();
+        if (!contains(context.getPackageManager().getPackagesForUid(uid), packageName)) {
+            throw new SecurityException("invalid package \"" + packageName + "\" for uid " + uid);
+        }
+        return fromBinderUnsafe(packageName, attributionTag);
+    }
+
+    /**
+     * Construct a CallerIdentity for test purposes.
+     */
+    @VisibleForTesting
+    public static CallerIdentity forTest(int uid, int pid, String packageName,
+            @Nullable String attributionTag) {
+        return new CallerIdentity(uid, pid, packageName, attributionTag);
+    }
+
+    /**
+     * Creates a CallerIdentity from the current binder identity, using the given package, feature
+     * id, and listener id. The package will not be checked to enforce that it belongs to the
+     * calling uid - this method should only be used if the package will be validated by some other
+     * means, such as an appops call.
+     */
+    public static CallerIdentity fromBinderUnsafe(String packageName,
+            @Nullable String attributionTag) {
+        return new CallerIdentity(Binder.getCallingUid(), Binder.getCallingPid(),
+                packageName, attributionTag);
+    }
+
+    private final int mUid;
+
+    private final int mPid;
+
+    private final String mPackageName;
+
+    private final @Nullable String mAttributionTag;
+
+
+    private CallerIdentity(int uid, int pid, String packageName,
+            @Nullable String attributionTag) {
+        this.mUid = uid;
+        this.mPid = pid;
+        this.mPackageName = Objects.requireNonNull(packageName);
+        this.mAttributionTag = attributionTag;
+    }
+
+    /** The calling UID. */
+    public int getUid() {
+        return mUid;
+    }
+
+    /** The calling PID. */
+    public int getPid() {
+        return mPid;
+    }
+
+    /** The calling package name. */
+    public String getPackageName() {
+        return mPackageName;
+    }
+
+    /** The calling attribution tag. */
+    public String getAttributionTag() {
+        return mAttributionTag;
+    }
+
+    /** Returns true if this represents a system server identity. */
+    public boolean isSystemServer() {
+        return mUid == Process.SYSTEM_UID;
+    }
+
+    @Override
+    public String toString() {
+        int length = 10 + mPackageName.length();
+        if (mAttributionTag != null) {
+            length += mAttributionTag.length();
+        }
+
+        StringBuilder builder = new StringBuilder(length);
+        builder.append(mUid).append("/").append(mPackageName);
+        if (mAttributionTag != null) {
+            builder.append("[");
+            if (mAttributionTag.startsWith(mPackageName)) {
+                builder.append(mAttributionTag.substring(mPackageName.length()));
+            } else {
+                builder.append(mAttributionTag);
+            }
+            builder.append("]");
+        }
+        return builder.toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof CallerIdentity)) {
+            return false;
+        }
+        CallerIdentity that = (CallerIdentity) o;
+        return mUid == that.mUid
+                && mPid == that.mPid
+                && mPackageName.equals(that.mPackageName)
+                && Objects.equals(mAttributionTag, that.mAttributionTag);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mUid, mPid, mPackageName, mAttributionTag);
+    }
+
+    private static <T> boolean contains(@Nullable T[] array, T value) {
+        return indexOf(array, value) != -1;
+    }
+
+    /**
+     * Return first index of {@code value} in {@code array}, or {@code -1} if
+     * not found.
+     */
+    private static <T> int indexOf(@Nullable T[] array, T value) {
+        if (array == null) return -1;
+        for (int i = 0; i < array.length; i++) {
+            if (Objects.equals(array[i], value)) return i;
+        }
+        return -1;
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/util/permissions/BroadcastPermissions.java b/nearby/service/java/com/android/server/nearby/util/permissions/BroadcastPermissions.java
new file mode 100644
index 0000000..c11c234
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/permissions/BroadcastPermissions.java
@@ -0,0 +1,105 @@
+/*
+ * 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.permissions;
+
+import static android.Manifest.permission.BLUETOOTH_ADVERTISE;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+
+import android.content.Context;
+
+import androidx.annotation.IntDef;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Utilities for handling presence broadcast runtime permissions. */
+public class BroadcastPermissions {
+
+    /** Indicates no permissions are present, or no permissions are required. */
+    public static final int PERMISSION_NONE = 0;
+
+    /** Indicates only the Bluetooth advertise permission is present, or is required. */
+    public static final int PERMISSION_BLUETOOTH_ADVERTISE = 1;
+
+    /** Broadcast permission levels. */
+    @IntDef({
+            PERMISSION_NONE,
+            PERMISSION_BLUETOOTH_ADVERTISE
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    @Target({TYPE_USE})
+    public @interface BroadcastPermissionLevel {}
+
+    /**
+     * Throws a security exception if the caller does not hold the required broadcast permissions.
+     */
+    public static void enforceBroadcastPermission(Context context, CallerIdentity callerIdentity) {
+        if (!checkCallerBroadcastPermission(context, callerIdentity)) {
+            throw new SecurityException("uid " + callerIdentity.getUid()
+                    + " does not have " + BLUETOOTH_ADVERTISE + ".");
+        }
+    }
+
+    /**
+     * Checks if the app has the permission to broadcast.
+     *
+     * @return true if the app does have the permission, false otherwise.
+     */
+    public static boolean checkCallerBroadcastPermission(Context context,
+            CallerIdentity callerIdentity) {
+        int uid = callerIdentity.getUid();
+        int pid = callerIdentity.getPid();
+
+        if (!checkBroadcastPermission(
+                getPermissionLevel(context, uid, pid), PERMISSION_BLUETOOTH_ADVERTISE)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /** Returns the permission level of the caller. */
+    @VisibleForTesting
+    @BroadcastPermissionLevel
+    public static int getPermissionLevel(
+            Context context, int uid, int pid) {
+        boolean isBluetoothAdvertiseGranted =
+                context.checkPermission(BLUETOOTH_ADVERTISE, pid, uid)
+                        == PERMISSION_GRANTED;
+        if (isBluetoothAdvertiseGranted) {
+            return PERMISSION_BLUETOOTH_ADVERTISE;
+        }
+
+        return PERMISSION_NONE;
+    }
+
+    /** Returns false if the given permission level does not meet the required permission level. */
+    private static boolean checkBroadcastPermission(
+            @BroadcastPermissionLevel int permissionLevel,
+            @BroadcastPermissionLevel int requiredPermissionLevel) {
+        return permissionLevel >= requiredPermissionLevel;
+    }
+
+    private BroadcastPermissions() {}
+}
+
diff --git a/nearby/service/java/com/android/server/nearby/util/permissions/DiscoveryPermissions.java b/nearby/service/java/com/android/server/nearby/util/permissions/DiscoveryPermissions.java
new file mode 100644
index 0000000..b0888ba
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/util/permissions/DiscoveryPermissions.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.util.permissions;
+
+import static android.Manifest.permission.BLUETOOTH_SCAN;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+import static java.lang.annotation.ElementType.TYPE_USE;
+
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.content.Context;
+
+import androidx.annotation.IntDef;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Utilities for handling presence discovery runtime permissions. */
+public class DiscoveryPermissions {
+
+    /** Indicates no permissions are present, or no permissions are required. */
+    public static final int PERMISSION_NONE = 0;
+
+    /** Indicates only the Bluetooth scan permission is present, or is required. */
+    public static final int PERMISSION_BLUETOOTH_SCAN = 1;
+
+    // String in AppOpsManager
+    @VisibleForTesting
+    public static final String OPSTR_BLUETOOTH_SCAN = "android:bluetooth_scan";
+
+    /** Discovery permission levels. */
+    @IntDef({
+            PERMISSION_NONE,
+            PERMISSION_BLUETOOTH_SCAN
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    @Target({TYPE_USE})
+    public @interface DiscoveryPermissionLevel {}
+
+    /**
+     * Throws a security exception if the caller does not hold the required scan permissions.
+     */
+    public static void enforceDiscoveryPermission(Context context, CallerIdentity callerIdentity) {
+        if (!checkCallerDiscoveryPermission(context, callerIdentity)) {
+            throw new SecurityException("uid " + callerIdentity.getUid() + " does not have "
+                    + BLUETOOTH_SCAN + ".");
+        }
+    }
+
+    /**
+     * Checks if the caller has the permission to scan.
+     */
+    public static boolean checkCallerDiscoveryPermission(Context context,
+            CallerIdentity callerIdentity) {
+        int uid = callerIdentity.getUid();
+        int pid = callerIdentity.getPid();
+
+        return checkDiscoveryPermission(
+                getPermissionLevel(context, uid, pid), PERMISSION_BLUETOOTH_SCAN);
+    }
+
+    /**
+     * Checks if the caller is allowed by AppOpsManager to scan.
+     */
+    public static boolean noteDiscoveryResultDelivery(AppOpsManager appOpsManager,
+            CallerIdentity callerIdentity) {
+        return noteAppOpAllowed(appOpsManager, callerIdentity, /* message= */ null);
+    }
+
+    private static boolean noteAppOpAllowed(AppOpsManager appOpsManager,
+            CallerIdentity identity, @Nullable String message) {
+        return appOpsManager.noteOp(asAppOp(PERMISSION_BLUETOOTH_SCAN),
+                identity.getUid(), identity.getPackageName(), identity.getAttributionTag(), message)
+                == AppOpsManager.MODE_ALLOWED;
+    }
+
+    /** Returns the permission level of the caller. */
+    public static @DiscoveryPermissionLevel int getPermissionLevel(
+            Context context, int uid, int pid) {
+        boolean isBluetoothScanGranted =
+                context.checkPermission(BLUETOOTH_SCAN, pid, uid) == PERMISSION_GRANTED;
+        if (isBluetoothScanGranted) {
+            return PERMISSION_BLUETOOTH_SCAN;
+        }
+        return PERMISSION_NONE;
+    }
+
+    /** Returns false if the given permission lev`el does not meet the required permission level. */
+    private static boolean checkDiscoveryPermission(
+            @DiscoveryPermissionLevel int permissionLevel,
+            @DiscoveryPermissionLevel int requiredPermissionLevel) {
+        return permissionLevel >= requiredPermissionLevel;
+    }
+
+    /** Returns the app op string according to the permission level. */
+    private static String asAppOp(@DiscoveryPermissionLevel int permissionLevel) {
+        if (permissionLevel == PERMISSION_BLUETOOTH_SCAN) {
+            return "android:bluetooth_scan";
+        }
+        throw new IllegalArgumentException();
+    }
+
+    private DiscoveryPermissions() {}
+}
diff --git a/nearby/tests/cts/fastpair/AndroidManifest.xml b/nearby/tests/cts/fastpair/AndroidManifest.xml
index ce841f2..96e2783 100644
--- a/nearby/tests/cts/fastpair/AndroidManifest.xml
+++ b/nearby/tests/cts/fastpair/AndroidManifest.xml
@@ -19,6 +19,8 @@
     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.BLUETOOTH_SCAN" />
+  <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
   <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
 
   <application>
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
index 9720865..6824ca6 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
@@ -16,6 +16,7 @@
 
 package android.nearby.cts;
 
+import static android.Manifest.permission.BLUETOOTH_PRIVILEGED;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
 import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
@@ -23,6 +24,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.junit.Assert.assertThrows;
+
 import android.app.UiAutomation;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothManager;
@@ -51,6 +54,7 @@
 
 import java.util.Collections;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 
@@ -73,10 +77,32 @@
     private UiAutomation mUiAutomation =
             InstrumentationRegistry.getInstrumentation().getUiAutomation();
 
+    private ScanRequest mScanRequest = new ScanRequest.Builder()
+            .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR)
+            .setScanMode(ScanRequest.SCAN_MODE_LOW_LATENCY)
+            .setBleEnabled(true)
+            .build();
+    private  ScanCallback mScanCallback = new ScanCallback() {
+        @Override
+        public void onDiscovered(@NonNull NearbyDevice device) {
+        }
+
+        @Override
+        public void onUpdated(@NonNull NearbyDevice device) {
+        }
+
+        @Override
+        public void onLost(@NonNull NearbyDevice device) {
+        }
+    };
+    private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
+
     @Before
     public void setUp() {
-        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG);
-        DeviceConfig.setProperty(NAMESPACE_TETHERING, "nearby_enable_presence_broadcast_legacy",
+        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG,
+                BLUETOOTH_PRIVILEGED);
+        DeviceConfig.setProperty(NAMESPACE_TETHERING,
+                "nearby_enable_presence_broadcast_legacy",
                 "true", false);
 
         mContext = InstrumentationRegistry.getContext();
@@ -88,28 +114,16 @@
     @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) {
-            }
+        mNearbyManager.startScan(mScanRequest, EXECUTOR, mScanCallback);
+        mNearbyManager.stopScan(mScanCallback);
+    }
 
-            @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 test_startScan_noPrivilegedPermission() {
+        mUiAutomation.dropShellPermissionIdentity();
+        assertThrows(SecurityException.class, () -> mNearbyManager
+                .startScan(mScanRequest, EXECUTOR, mScanCallback));
     }
 
     @Test
diff --git a/nearby/tests/unit/AndroidManifest.xml b/nearby/tests/unit/AndroidManifest.xml
index 88c0f5f..9f58baf 100644
--- a/nearby/tests/unit/AndroidManifest.xml
+++ b/nearby/tests/unit/AndroidManifest.xml
@@ -22,6 +22,7 @@
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.BLUETOOTH" />
     <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
 
     <application android:debuggable="true">
         <uses-library android:name="android.test.runner" />
diff --git a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
index 31965a4..e250254 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
@@ -16,10 +16,18 @@
 
 package com.android.server.nearby;
 
+import static android.Manifest.permission.BLUETOOTH_PRIVILEGED;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 import static org.mockito.MockitoAnnotations.initMocks;
 
+import android.app.AppOpsManager;
 import android.app.UiAutomation;
 import android.content.Context;
 import android.nearby.IScanListener;
@@ -27,12 +35,17 @@
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.server.nearby.injector.Injector;
+import com.android.server.nearby.util.permissions.DiscoveryPermissions;
+
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.Mock;
 
 public final class NearbyServiceTest {
 
+    private static final String PACKAGE_NAME = "android.nearby.test";
     private Context mContext;
     private NearbyService mService;
     private ScanRequest mScanRequest;
@@ -41,19 +54,36 @@
 
     @Mock
     private IScanListener mScanListener;
+    @Mock
+    private AppOpsManager mMockAppOpsManager;
 
     @Before
-    public void setup() {
+    public void setUp()  {
         initMocks(this);
-        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG);
+        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, BLUETOOTH_PRIVILEGED);
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
         mService = new NearbyService(mContext);
         mScanRequest = createScanRequest();
     }
 
+    @After
+    public void tearDown() {
+        mUiAutomation.dropShellPermissionIdentity();
+    }
+
     @Test
     public void test_register() {
-        mService.registerScanListener(mScanRequest, mScanListener);
+        setMockInjector(/* isMockOpsAllowed= */ true);
+        mService.registerScanListener(mScanRequest, mScanListener, PACKAGE_NAME,
+                /* attributionTag= */ null);
+    }
+
+    @Test
+    public void test_register_noPrivilegedPermission_throwsException() {
+        mUiAutomation.dropShellPermissionIdentity();
+        assertThrows(java.lang.SecurityException.class,
+                () -> mService.registerScanListener(mScanRequest, mScanListener, PACKAGE_NAME,
+                        /* attributionTag= */ null));
     }
 
     @Test
@@ -67,4 +97,14 @@
                 .setBleEnabled(true)
                 .build();
     }
+
+    private void setMockInjector(boolean isMockOpsAllowed) {
+        Injector injector = mock(Injector.class);
+        when(injector.getAppOpsManager()).thenReturn(mMockAppOpsManager);
+        when(mMockAppOpsManager.noteOp(eq(DiscoveryPermissions.OPSTR_BLUETOOTH_SCAN),
+                anyInt(), eq(PACKAGE_NAME), nullable(String.class), nullable(String.class)))
+                .thenReturn(isMockOpsAllowed
+                        ? AppOpsManager.MODE_ALLOWED : AppOpsManager.MODE_ERRORED);
+        mService.setInjector(injector);
+    }
 }
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
index d32e325..3b34655 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceManagerTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/presence/PresenceManagerTest.java
@@ -16,9 +16,6 @@
 
 package com.android.server.nearby.presence;
 
-
-
-
 import androidx.test.filters.SdkSuppress;
 
 import org.junit.Before;
@@ -26,7 +23,6 @@
 import org.mockito.MockitoAnnotations;
 
 public class PresenceManagerTest {
-    private PresenceManager mPresenceManager;
 
     @Before
     public void setup() {
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java
index f485e18..d06a785 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/BleBroadcastProviderTest.java
@@ -20,6 +20,7 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import android.app.AppOpsManager;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothManager;
 import android.bluetooth.le.AdvertiseSettings;
@@ -61,7 +62,6 @@
     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);
@@ -74,7 +74,8 @@
         mBleBroadcastProvider.start(advertiseBytes, mBroadcastListener);
 
         mBleBroadcastProvider.onStartFailure(BroadcastCallback.STATUS_FAILURE);
-        verify(mBroadcastListener, times(2)).onStatusChanged(eq(BroadcastCallback.STATUS_FAILURE));
+        verify(mBroadcastListener, times(1))
+                .onStatusChanged(eq(BroadcastCallback.STATUS_FAILURE));
     }
 
     private static class TestInjector implements Injector {
@@ -90,5 +91,10 @@
         public ContextHubManagerAdapter getContextHubManagerAdapter() {
             return null;
         }
+
+        @Override
+        public AppOpsManager getAppOpsManager() {
+            return null;
+        }
     }
 }
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java
index 8e97443..902cc33 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/BleDiscoveryProviderTest.java
@@ -23,6 +23,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.MockitoAnnotations.initMocks;
 
+import android.app.AppOpsManager;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothDevice;
 import android.bluetooth.BluetoothManager;
@@ -87,6 +88,11 @@
         public ContextHubManagerAdapter getContextHubManagerAdapter() {
             return null;
         }
+
+        @Override
+        public AppOpsManager getAppOpsManager() {
+            return null;
+        }
     }
 
     private ScanResult createScanResult() {
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/BroadcastPermissionsTest.java b/nearby/tests/unit/src/com/android/server/nearby/util/BroadcastPermissionsTest.java
new file mode 100644
index 0000000..1a22412
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/BroadcastPermissionsTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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 android.Manifest.permission.BLUETOOTH_ADVERTISE;
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+import static com.android.server.nearby.util.permissions.BroadcastPermissions.PERMISSION_BLUETOOTH_ADVERTISE;
+import static com.android.server.nearby.util.permissions.BroadcastPermissions.PERMISSION_NONE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.content.Context;
+
+import com.android.server.nearby.util.identity.CallerIdentity;
+import com.android.server.nearby.util.permissions.BroadcastPermissions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+/**
+ * Unit test for {@link BroadcastPermissions}
+ */
+public final class BroadcastPermissionsTest {
+
+    private static final String PACKAGE_NAME = "android.nearby.test";
+    private static final int UID = 1234;
+    private static final int PID = 5678;
+    private CallerIdentity mCallerIdentity;
+
+    @Mock private Context mMockContext;
+
+    @Before
+    public void setup() {
+        initMocks(this);
+        mCallerIdentity = CallerIdentity
+                .forTest(UID, PID, PACKAGE_NAME, /* attributionTag= */ null);
+    }
+
+    @Test
+    public void test_checkCallerBroadcastPermission_granted() {
+        when(mMockContext.checkPermission(BLUETOOTH_ADVERTISE, PID, UID))
+                .thenReturn(PERMISSION_GRANTED);
+
+        assertThat(BroadcastPermissions
+                .checkCallerBroadcastPermission(mMockContext, mCallerIdentity))
+                .isTrue();
+    }
+
+    @Test
+    public void test_checkCallerBroadcastPermission_deniedPermission() {
+        when(mMockContext.checkPermission(BLUETOOTH_ADVERTISE, PID, UID))
+                .thenReturn(PERMISSION_DENIED);
+
+        assertThat(BroadcastPermissions
+                .checkCallerBroadcastPermission(mMockContext, mCallerIdentity))
+                .isFalse();
+    }
+
+    @Test
+    public void test_getPermissionLevel_none() {
+        when(mMockContext.checkPermission(BLUETOOTH_ADVERTISE, PID, UID))
+                .thenReturn(PERMISSION_DENIED);
+
+        assertThat(BroadcastPermissions.getPermissionLevel(mMockContext, UID, PID))
+                .isEqualTo(PERMISSION_NONE);
+    }
+
+    @Test
+    public void test_getPermissionLevel_advertising() {
+        when(mMockContext.checkPermission(BLUETOOTH_ADVERTISE, PID, UID))
+                .thenReturn(PERMISSION_GRANTED);
+
+        assertThat(BroadcastPermissions.getPermissionLevel(mMockContext, UID, PID))
+                .isEqualTo(PERMISSION_BLUETOOTH_ADVERTISE);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/util/DiscoveryPermissionsTest.java b/nearby/tests/unit/src/com/android/server/nearby/util/DiscoveryPermissionsTest.java
new file mode 100644
index 0000000..d953a60
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/util/DiscoveryPermissionsTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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 android.Manifest.permission.BLUETOOTH_SCAN;
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+import static com.android.server.nearby.util.permissions.DiscoveryPermissions.PERMISSION_BLUETOOTH_SCAN;
+import static com.android.server.nearby.util.permissions.DiscoveryPermissions.PERMISSION_NONE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.app.AppOpsManager;
+import android.content.Context;
+
+import com.android.server.nearby.util.identity.CallerIdentity;
+import com.android.server.nearby.util.permissions.DiscoveryPermissions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+/**
+ * Unit test for {@link DiscoveryPermissions}
+ */
+public final class DiscoveryPermissionsTest {
+
+    private static final String PACKAGE_NAME = "android.nearby.test";
+    private static final int UID = 1234;
+    private static final int PID = 5678;
+    private CallerIdentity mCallerIdentity;
+
+    @Mock
+    private Context mMockContext;
+    @Mock private AppOpsManager mMockAppOps;
+
+    @Before
+    public void setup() {
+        initMocks(this);
+        mCallerIdentity = CallerIdentity
+                .forTest(UID, PID, PACKAGE_NAME, /* attributionTag= */ null);
+    }
+
+    @Test
+    public void test_enforceCallerDiscoveryPermission_exception() {
+        when(mMockContext.checkPermission(BLUETOOTH_SCAN, PID, UID)).thenReturn(PERMISSION_DENIED);
+
+        assertThrows(SecurityException.class,
+                () -> DiscoveryPermissions
+                        .enforceDiscoveryPermission(mMockContext, mCallerIdentity));
+    }
+
+    @Test
+    public void test_checkCallerDiscoveryPermission_granted() {
+        when(mMockContext.checkPermission(BLUETOOTH_SCAN, PID, UID)).thenReturn(PERMISSION_GRANTED);
+
+        assertThat(DiscoveryPermissions
+                .checkCallerDiscoveryPermission(mMockContext, mCallerIdentity))
+                .isTrue();
+    }
+
+    @Test
+    public void test_checkCallerDiscoveryPermission_denied() {
+        when(mMockContext.checkPermission(BLUETOOTH_SCAN, PID, UID)).thenReturn(PERMISSION_DENIED);
+
+        assertThat(DiscoveryPermissions
+                .checkCallerDiscoveryPermission(mMockContext, mCallerIdentity))
+                .isFalse();
+    }
+
+    @Test
+    public void test_checkNoteOpPermission_granted() {
+        when(mMockAppOps.noteOp(DiscoveryPermissions.OPSTR_BLUETOOTH_SCAN, UID, PACKAGE_NAME,
+                null, null)).thenReturn(AppOpsManager.MODE_ALLOWED);
+
+        assertThat(DiscoveryPermissions
+                .noteDiscoveryResultDelivery(mMockAppOps, mCallerIdentity))
+                .isTrue();
+    }
+
+    @Test
+    public void test_checkNoteOpPermission_denied() {
+        when(mMockAppOps.noteOp(DiscoveryPermissions.OPSTR_BLUETOOTH_SCAN, UID, PACKAGE_NAME,
+                null, null)).thenReturn(AppOpsManager.MODE_ERRORED);
+
+        assertThat(DiscoveryPermissions
+                .noteDiscoveryResultDelivery(mMockAppOps, mCallerIdentity))
+                .isFalse();
+    }
+
+    @Test
+    public void test_getPermissionLevel_none() {
+        when(mMockContext.checkPermission(BLUETOOTH_SCAN, PID, UID)).thenReturn(PERMISSION_DENIED);
+
+        assertThat(DiscoveryPermissions
+                .getPermissionLevel(mMockContext, UID, PID))
+                .isEqualTo(PERMISSION_NONE);
+    }
+
+    @Test
+    public void test_getPermissionLevel_scan() {
+        when(mMockContext.checkPermission(BLUETOOTH_SCAN, PID, UID))
+                .thenReturn(PERMISSION_GRANTED);
+
+        assertThat(DiscoveryPermissions
+                .getPermissionLevel(mMockContext, UID, PID)).isEqualTo(PERMISSION_BLUETOOTH_SCAN);
+    }
+}