Merge "Add Multiplexer logic" into udc-dev
diff --git a/nearby/service/java/com/android/server/nearby/managers/ListenerMultiplexer.java b/nearby/service/java/com/android/server/nearby/managers/ListenerMultiplexer.java
new file mode 100644
index 0000000..a6a9388
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/ListenerMultiplexer.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.NonNull;
+import android.os.IBinder;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.server.nearby.managers.registration.BinderListenerRegistration;
+import com.android.server.nearby.managers.registration.BinderListenerRegistration.ListenerOperation;
+
+import java.util.Collection;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * A simplified class based on {@link com.android.server.location.listeners.ListenerMultiplexer}.
+ * It is a base class to multiplex broadcast and discovery events to multiple listener
+ * registrations. Every listener is represented by a registration object which stores all required
+ * state for a listener.
+ * Registrations will be merged to one request for the service to operate.
+ *
+ * @param <TListener>           callback type for clients
+ * @param <TRegistration>       child of {@link BinderListenerRegistration}
+ * @param <TMergedRegistration> merged registration type
+ */
+public abstract class ListenerMultiplexer<TListener,
+        TRegistration extends BinderListenerRegistration<TListener>, TMergedRegistration> {
+
+    /**
+     * The lock object used by the multiplexer. Acquiring this lock allows for multiple operations
+     * on the multiplexer to be completed atomically. Otherwise, it is not required to hold this
+     * lock. This lock is held while invoking all lifecycle callbacks on both the multiplexer and
+     * any registrations.
+     */
+    public final Object mMultiplexerLock = new Object();
+
+    @GuardedBy("mMultiplexerLock")
+    final ArrayMap<IBinder, TRegistration> mRegistrations = new ArrayMap<>();
+
+    // this is really @NonNull in many ways, but we explicitly null this out to allow for GC when
+    // not
+    // in use, so we can't annotate with @NonNull
+    @GuardedBy("mMultiplexerLock")
+    public TMergedRegistration mMerged;
+
+    /**
+     * Invoked when the multiplexer goes from having no registrations to having some registrations.
+     * This is a convenient entry point for registering listeners, etc, which only need to be
+     * present
+     * while there are any registrations. Invoked while holding the multiplexer's internal lock.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public void onRegister() {
+        Log.v(TAG, "ListenerMultiplexer registered.");
+    }
+
+    /**
+     * Invoked when the multiplexer goes from having some registrations to having no registrations.
+     * This is a convenient entry point for unregistering listeners, etc, which only need to be
+     * present while there are any registrations. Invoked while holding the multiplexer's internal
+     * lock.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public void onUnregister() {
+        Log.v(TAG, "ListenerMultiplexer unregistered.");
+    }
+
+    /**
+     * Puts a new registration with the given key, replacing any previous registration under the
+     * same key. This method cannot be called to put a registration re-entrantly.
+     */
+    public final void putRegistration(@NonNull IBinder key, @NonNull TRegistration registration) {
+        Objects.requireNonNull(key);
+        Objects.requireNonNull(registration);
+        synchronized (mMultiplexerLock) {
+            boolean wasEmpty = mRegistrations.isEmpty();
+
+            int index = mRegistrations.indexOfKey(key);
+            if (index > 0) {
+                BinderListenerRegistration<TListener> oldRegistration = mRegistrations.valueAt(
+                        index);
+                oldRegistration.onUnregister();
+                mRegistrations.setValueAt(index, registration);
+            } else {
+                mRegistrations.put(key, registration);
+            }
+
+            registration.onRegister();
+            onRegistrationsUpdated();
+            if (wasEmpty) {
+                onRegister();
+            }
+        }
+    }
+
+    /**
+     * Removes the registration with the given key.
+     */
+    public final void removeRegistration(IBinder key) {
+        synchronized (mMultiplexerLock) {
+            int index = mRegistrations.indexOfKey(key);
+            if (index < 0) {
+                return;
+            }
+
+            removeRegistration(index);
+        }
+    }
+
+    @GuardedBy("mMultiplexerLock")
+    private void removeRegistration(int index) {
+        TRegistration registration = mRegistrations.valueAt(index);
+
+        registration.onUnregister();
+        mRegistrations.removeAt(index);
+
+        onRegistrationsUpdated();
+
+        if (mRegistrations.isEmpty()) {
+            onUnregister();
+        }
+    }
+
+    /**
+     * Invoked when a registration is added, removed, or replaced. Invoked while holding the
+     * multiplexer's internal lock.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public final void onRegistrationsUpdated() {
+        TMergedRegistration newMerged = mergeRegistrations(mRegistrations.values());
+        if (newMerged.equals(mMerged)) {
+            return;
+        }
+        mMerged = newMerged;
+        onMergedRegistrationsUpdated();
+    }
+
+    /**
+     * Called in order to generate a merged registration from the given set of active registrations.
+     * The list of registrations will never be empty. If the resulting merged registration is equal
+     * to the currently registered merged registration, nothing further will happen. If the merged
+     * registration differs,{@link #onMergedRegistrationsUpdated()} will be invoked with the new
+     * merged registration so that the backing service can be updated.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public abstract TMergedRegistration mergeRegistrations(
+            @NonNull Collection<TRegistration> registrations);
+
+    /**
+     * The operation that the manager wants to handle when there is an update for the merged
+     * registration.
+     */
+    @GuardedBy("mMultiplexerLock")
+    public abstract void onMergedRegistrationsUpdated();
+
+    protected final void deliverToListeners(
+            Function<TRegistration, ListenerOperation<TListener>> function) {
+        synchronized (mMultiplexerLock) {
+            final int size = mRegistrations.size();
+            for (int i = 0; i < size; i++) {
+                TRegistration registration = mRegistrations.valueAt(i);
+                BinderListenerRegistration.ListenerOperation<TListener> operation = function.apply(
+                        registration);
+                if (operation != null) {
+                    registration.executeOperation(operation);
+                }
+            }
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/MergedDiscoveryRequest.java b/nearby/service/java/com/android/server/nearby/managers/MergedDiscoveryRequest.java
new file mode 100644
index 0000000..dcfb602
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/MergedDiscoveryRequest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers;
+
+import android.annotation.IntDef;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.util.ArraySet;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.Collection;
+import java.util.Set;
+
+/** Internal discovery request to {@link DiscoveryProviderManager} and providers */
+public class MergedDiscoveryRequest {
+
+    private static final MergedDiscoveryRequest EMPTY_REQUEST = new MergedDiscoveryRequest(
+            /* scanMode= */ ScanRequest.SCAN_MODE_NO_POWER,
+            /* scanTypes= */ ImmutableSet.of(),
+            /* actions= */ ImmutableSet.of(),
+            /* scanFilters= */ ImmutableSet.of(),
+            /* mediums= */ ImmutableSet.of());
+    @ScanRequest.ScanMode
+    private final int mScanMode;
+    private final Set<Integer> mScanTypes;
+    private final Set<Integer> mActions;
+    private final Set<ScanFilter> mScanFilters;
+    private final Set<Integer> mMediums;
+
+    private MergedDiscoveryRequest(@ScanRequest.ScanMode int scanMode, Set<Integer> scanTypes,
+            Set<Integer> actions, Set<ScanFilter> scanFilters, Set<Integer> mediums) {
+        mScanMode = scanMode;
+        mScanTypes = scanTypes;
+        mActions = actions;
+        mScanFilters = scanFilters;
+        mMediums = mediums;
+    }
+
+    /**
+     * Returns an empty discovery request.
+     *
+     * <p>The empty request is used as the default request when the discovery engine is enabled,
+     * but
+     * there is no request yet. It's also used to notify the discovery engine all clients have
+     * removed
+     * their requests.
+     */
+    public static MergedDiscoveryRequest empty() {
+        return EMPTY_REQUEST;
+    }
+
+    /** Returns the priority of the request */
+    @ScanRequest.ScanMode
+    public final int getScanMode() {
+        return mScanMode;
+    }
+
+    /** Returns all requested scan types. */
+    public ImmutableSet<Integer> getScanTypes() {
+        return ImmutableSet.copyOf(mScanTypes);
+    }
+
+    /** Returns the actions of the request */
+    public ImmutableSet<Integer> getActions() {
+        return ImmutableSet.copyOf(mActions);
+    }
+
+    /** Returns the scan filters of the request */
+    public ImmutableSet<ScanFilter> getScanFilters() {
+        return ImmutableSet.copyOf(mScanFilters);
+    }
+
+    /** Returns the enabled scan mediums */
+    public ImmutableSet<Integer> getMediums() {
+        return ImmutableSet.copyOf(mMediums);
+    }
+
+    /**
+     * The medium where the broadcast request should be sent.
+     *
+     * @hide
+     */
+    @IntDef({Medium.BLE})
+    public @interface Medium {
+        int BLE = 1;
+    }
+
+    /** Builder for {@link MergedDiscoveryRequest}. */
+    public static class Builder {
+        private final Set<Integer> mScanTypes;
+        private final Set<Integer> mActions;
+        private final Set<ScanFilter> mScanFilters;
+        private final Set<Integer> mMediums;
+        @ScanRequest.ScanMode
+        private int mScanMode;
+
+        public Builder() {
+            mScanMode = ScanRequest.SCAN_MODE_NO_POWER;
+            mScanTypes = new ArraySet<>();
+            mActions = new ArraySet<>();
+            mScanFilters = new ArraySet<>();
+            mMediums = new ArraySet<>();
+        }
+
+        /**
+         * Sets the priority for the engine request.
+         */
+        public Builder setScanMode(@ScanRequest.ScanMode int scanMode) {
+            mScanMode = scanMode;
+            return this;
+        }
+
+        /**
+         * Adds scan type to the request.
+         */
+        public Builder addScanType(@ScanRequest.ScanType int type) {
+            mScanTypes.add(type);
+            return this;
+        }
+
+        /** Add actions to the request. */
+        public Builder addActions(Collection<Integer> actions) {
+            mActions.addAll(actions);
+            return this;
+        }
+
+        /** Add actions to the request. */
+        public Builder addScanFilters(Collection<ScanFilter> scanFilters) {
+            mScanFilters.addAll(scanFilters);
+            return this;
+        }
+
+        /**
+         * Add mediums to the request.
+         */
+        public Builder addMedium(@Medium int medium) {
+            mMediums.add(medium);
+            return this;
+        }
+
+        /** Builds an instance of {@link MergedDiscoveryRequest}. */
+        public MergedDiscoveryRequest build() {
+            return new MergedDiscoveryRequest(mScanMode, mScanTypes, mActions, mScanFilters,
+                    mMediums);
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/registration/BinderListenerRegistration.java b/nearby/service/java/com/android/server/nearby/managers/registration/BinderListenerRegistration.java
new file mode 100644
index 0000000..4aaa08f
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/registration/BinderListenerRegistration.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers.registration;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.Nullable;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.server.nearby.managers.ListenerMultiplexer;
+
+import java.util.NoSuchElementException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A listener registration object which holds data associated with the listener, such as an optional
+ * request, and an executor responsible for listener invocations. Key is the IBinder.
+ *
+ * @param <TListener> listener for the callback
+ */
+public abstract class BinderListenerRegistration<TListener> implements IBinder.DeathRecipient {
+
+    private final AtomicBoolean mRemoved = new AtomicBoolean(false);
+    private final Executor mExecutor;
+    private final Object mListenerLock = new Object();
+    @Nullable
+    TListener mListener;
+    @Nullable
+    private final IBinder mKey;
+
+    public BinderListenerRegistration(IBinder key, Executor executor, TListener listener) {
+        this.mKey = key;
+        this.mExecutor = executor;
+        this.mListener = listener;
+    }
+
+    /**
+     * Must be implemented to return the
+     * {@link com.android.server.nearby.managers.ListenerMultiplexer} this registration is
+     * registered
+     * with. Often this is easiest to accomplish by defining registration subclasses as non-static
+     * inner classes of the multiplexer they are to be used with.
+     */
+    public abstract ListenerMultiplexer<TListener, ?
+            extends BinderListenerRegistration<TListener>, ?> getOwner();
+
+    public final IBinder getBinder() {
+        return mKey;
+    }
+
+    public final Executor getExecutor() {
+        return mExecutor;
+    }
+
+    /**
+     * Called when the registration is put in the Multiplexer.
+     */
+    public void onRegister() {
+        try {
+            getBinder().linkToDeath(this, 0);
+        } catch (RemoteException e) {
+            remove();
+        }
+    }
+
+    /**
+     * Called when the registration is removed in the Multiplexer.
+     */
+    public void onUnregister() {
+        this.mListener = null;
+        try {
+            getBinder().unlinkToDeath(this, 0);
+        } catch (NoSuchElementException e) {
+            Log.w(TAG, "failed to unregister binder death listener", e);
+        }
+    }
+
+    /**
+     * Removes this registration. All pending listener invocations will fail.
+     *
+     * <p>Does nothing if invoked before {@link #onRegister()} or after {@link #onUnregister()}.
+     */
+    public final void remove() {
+        IBinder key = mKey;
+        if (key != null && !mRemoved.getAndSet(true)) {
+            getOwner().removeRegistration(key);
+        }
+    }
+
+    @Override
+    public void binderDied() {
+        remove();
+    }
+
+    /**
+     * May be overridden by subclasses to handle listener operation failures. The default behavior
+     * is
+     * to further propagate any exceptions. Will always be invoked on the executor thread.
+     */
+    protected void onOperationFailure(Exception exception) {
+        throw new AssertionError(exception);
+    }
+
+    /**
+     * Executes the given listener operation on the registration executor, invoking {@link
+     * #onOperationFailure(Exception)} in case the listener operation fails. If the registration is
+     * removed prior to the operation running, the operation is considered canceled. If a null
+     * operation is supplied, nothing happens.
+     */
+    public final void executeOperation(@Nullable ListenerOperation<TListener> operation) {
+        if (operation == null) {
+            return;
+        }
+
+        synchronized (mListenerLock) {
+            if (mListener == null) {
+                return;
+            }
+
+            AtomicBoolean complete = new AtomicBoolean(false);
+            mExecutor.execute(() -> {
+                TListener listener;
+                synchronized (mListenerLock) {
+                    listener = mListener;
+                }
+
+                Exception failure = null;
+                if (listener != null) {
+                    try {
+                        operation.operate(listener);
+                    } catch (Exception e) {
+                        if (e instanceof RuntimeException) {
+                            throw (RuntimeException) e;
+                        } else {
+                            failure = e;
+                        }
+                    }
+                }
+
+                operation.onComplete(failure == null);
+                complete.set(true);
+
+                if (failure != null) {
+                    onOperationFailure(failure);
+                }
+            });
+            operation.onScheduled(complete.get());
+        }
+    }
+
+    /**
+     * An listener operation to perform.
+     *
+     * @param <ListenerT> listener type
+     */
+    public interface ListenerOperation<ListenerT> {
+
+        /**
+         * Invoked after the operation has been scheduled for execution. The {@code complete}
+         * argument
+         * will be true if {@link #onComplete(boolean)} was invoked prior to this callback (such as
+         * if
+         * using a direct executor), or false if {@link #onComplete(boolean)} will be invoked after
+         * this
+         * callback. This method is always invoked on the calling thread.
+         */
+        default void onScheduled(boolean complete) {
+        }
+
+        /**
+         * Invoked to perform an operation on the given listener. This method is always invoked on
+         * the
+         * executor thread. If this method throws a checked exception, the operation will fail and
+         * result in {@link #onOperationFailure(Exception)} being invoked. If this method throws an
+         * unchecked exception, this propagates normally and should result in a crash.
+         */
+        void operate(ListenerT listener) throws Exception;
+
+        /**
+         * Invoked after the operation is complete. The {@code success} argument will be true if
+         * the
+         * operation completed without throwing any exceptions, and false otherwise (such as if the
+         * operation was canceled prior to executing, or if it threw an exception). This invocation
+         * may
+         * happen either before or after (but never during) the invocation of {@link
+         * #onScheduled(boolean)}. This method is always invoked on the executor thread.
+         */
+        default void onComplete(boolean success) {
+        }
+    }
+}
diff --git a/nearby/service/java/com/android/server/nearby/managers/registration/DiscoveryRegistration.java b/nearby/service/java/com/android/server/nearby/managers/registration/DiscoveryRegistration.java
new file mode 100644
index 0000000..1cb70ac
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/managers/registration/DiscoveryRegistration.java
@@ -0,0 +1,358 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers.registration;
+
+import static android.nearby.ScanRequest.SCAN_TYPE_NEARBY_PRESENCE;
+
+import static com.android.server.nearby.NearbyService.TAG;
+
+import android.annotation.IntDef;
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceScanFilter;
+import android.nearby.ScanCallback;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.nearby.common.CancelableAlarm;
+import com.android.server.nearby.managers.ListenerMultiplexer;
+import com.android.server.nearby.managers.MergedDiscoveryRequest;
+import com.android.server.nearby.presence.PresenceDiscoveryResult;
+import com.android.server.nearby.util.identity.CallerIdentity;
+import com.android.server.nearby.util.permissions.DiscoveryPermissions;
+
+import com.google.common.collect.ImmutableSet;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.stream.Collectors;
+
+/**
+ * Class responsible for all client based operations. Each {@link DiscoveryRegistration} is for one
+ * valid unique {@link android.nearby.NearbyManager#startScan(ScanRequest, Executor, ScanCallback)}
+ */
+public class DiscoveryRegistration extends BinderListenerRegistration<IScanListener> {
+
+    /**
+     * Timeout before a previous discovered device is reported as lost.
+     */
+    @VisibleForTesting
+    static final int ON_LOST_TIME_OUT_MS = 10000;
+    /** Lock for registration operations. */
+    final Object mMultiplexerLock;
+    private final ListenerMultiplexer<IScanListener, DiscoveryRegistration, MergedDiscoveryRequest>
+            mOwner;
+    private final AppOpsManager mAppOpsManager;
+    /** Presence devices that are currently discovered, and not lost yet. */
+    @GuardedBy("mMultiplexerLock")
+    private final Map<Long, NearbyDeviceParcelable> mDiscoveredDevices;
+    /** A map of deviceId and alarms for reporting device lost. */
+    @GuardedBy("mMultiplexerLock")
+    private final Map<Long, DeviceOnLostAlarm> mDiscoveryOnLostAlarmPerDevice = new ArrayMap<>();
+    /**
+     * The single thread executor to run {@link CancelableAlarm} to report
+     * {@link NearbyDeviceParcelable} on lost after timeout.
+     */
+    private final ScheduledExecutorService mAlarmExecutor =
+            Executors.newSingleThreadScheduledExecutor();
+    private final ScanRequest mScanRequest;
+    private final CallerIdentity mCallerIdentity;
+
+    public DiscoveryRegistration(
+            ListenerMultiplexer<IScanListener, DiscoveryRegistration, MergedDiscoveryRequest> owner,
+            ScanRequest scanRequest, IScanListener scanListener, Executor executor,
+            CallerIdentity callerIdentity, Object multiplexerLock, AppOpsManager appOpsManager) {
+        super(scanListener.asBinder(), executor, scanListener);
+        mOwner = owner;
+        mListener = scanListener;
+        mScanRequest = scanRequest;
+        mCallerIdentity = callerIdentity;
+        mMultiplexerLock = multiplexerLock;
+        mDiscoveredDevices = new ArrayMap<>();
+        mAppOpsManager = appOpsManager;
+    }
+
+    /**
+     * Gets the scan request.
+     */
+    public ScanRequest getScanRequest() {
+        return mScanRequest;
+    }
+
+    /**
+     * Gets the actions from the scan filter(s).
+     */
+    public Set<Integer> getActions() {
+        Set<Integer> result = new ArraySet<>();
+        List<ScanFilter> filters = mScanRequest.getScanFilters();
+        for (ScanFilter filter : filters) {
+            if (filter instanceof PresenceScanFilter) {
+                result.addAll(((PresenceScanFilter) filter).getPresenceActions());
+            }
+        }
+        return ImmutableSet.copyOf(result);
+    }
+
+    /**
+     * Gets all the filters that are for Nearby Presence.
+     */
+    public Set<ScanFilter> getPresenceScanFilters() {
+        Set<ScanFilter> result = new ArraySet<>();
+        List<ScanFilter> filters = mScanRequest.getScanFilters();
+        for (ScanFilter filter : filters) {
+            if (filter.getType() == SCAN_TYPE_NEARBY_PRESENCE) {
+                result.add(filter);
+            }
+        }
+        return ImmutableSet.copyOf(result);
+    }
+
+    @VisibleForTesting
+    Map<Long, DeviceOnLostAlarm> getDiscoveryOnLostAlarms() {
+        synchronized (mMultiplexerLock) {
+            return mDiscoveryOnLostAlarmPerDevice;
+        }
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other instanceof DiscoveryRegistration) {
+            DiscoveryRegistration otherRegistration = (DiscoveryRegistration) other;
+            return Objects.equals(mScanRequest, otherRegistration.mScanRequest) && Objects.equals(
+                    mListener, otherRegistration.mListener);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mListener, mScanRequest);
+    }
+
+    @Override
+    public ListenerMultiplexer<
+            IScanListener, DiscoveryRegistration, MergedDiscoveryRequest> getOwner() {
+        return mOwner;
+    }
+
+    @VisibleForTesting
+    ListenerOperation<IScanListener> reportDeviceLost(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        return reportResult(DiscoveryResult.DEVICE_LOST, device, () -> {
+            synchronized (mMultiplexerLock) {
+                // Remove the device from reporting devices after reporting lost.
+                mDiscoveredDevices.remove(deviceId);
+                DeviceOnLostAlarm alarm = mDiscoveryOnLostAlarmPerDevice.remove(deviceId);
+                if (alarm != null) {
+                    alarm.cancel();
+                }
+            }
+        });
+    }
+
+    /**
+     * Called when there is device discovered from the server.
+     */
+    public ListenerOperation<IScanListener> onNearbyDeviceDiscovered(
+            NearbyDeviceParcelable device) {
+        if (!filterCheck(device)) {
+            Log.d(TAG, "presence filter does not match for the scanned Presence Device");
+            return null;
+        }
+        synchronized (mMultiplexerLock) {
+            long deviceId = device.getDeviceId();
+            boolean deviceReported = mDiscoveredDevices.containsKey(deviceId);
+            scheduleOnLostAlarm(device);
+            if (deviceReported) {
+                NearbyDeviceParcelable oldDevice = mDiscoveredDevices.get(deviceId);
+                if (device.equals(oldDevice)) {
+                    return null;
+                }
+                return reportUpdated(device);
+            }
+            return reportDiscovered(device);
+        }
+    }
+
+    @VisibleForTesting
+    static boolean presenceFilterMatches(NearbyDeviceParcelable device,
+            List<ScanFilter> scanFilters) {
+        if (scanFilters.isEmpty()) {
+            return true;
+        }
+        PresenceDiscoveryResult discoveryResult = PresenceDiscoveryResult.fromDevice(device);
+        for (ScanFilter scanFilter : scanFilters) {
+            PresenceScanFilter presenceScanFilter = (PresenceScanFilter) scanFilter;
+            if (discoveryResult.matches(presenceScanFilter)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Nullable
+    ListenerOperation<IScanListener> reportDiscovered(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        return reportResult(DiscoveryResult.DEVICE_DISCOVERED, device, () -> {
+            synchronized (mMultiplexerLock) {
+                // Add the device to discovered devices after reporting device is
+                // discovered.
+                mDiscoveredDevices.put(deviceId, device);
+                scheduleOnLostAlarm(device);
+            }
+        });
+    }
+
+    @Nullable
+    ListenerOperation<IScanListener> reportUpdated(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        return reportResult(DiscoveryResult.DEVICE_UPDATED, device, () -> {
+            synchronized (mMultiplexerLock) {
+                // Update the new device to discovered devices after reporting device is
+                // discovered.
+                mDiscoveredDevices.put(deviceId, device);
+                scheduleOnLostAlarm(device);
+            }
+        });
+
+    }
+
+    @Nullable
+    ListenerOperation<IScanListener> reportResult(@DiscoveryResult int result,
+            NearbyDeviceParcelable device, @Nullable Runnable successReportCallback) {
+        // Report the operation to AppOps.
+        // NOTE: AppOps report has to be the last operation before delivering the result. Otherwise
+        // we may over-report when the discovery result doesn't end up being delivered.
+        if (!checkIdentity()) {
+            return listener -> listener.onError(ScanCallback.ERROR_PERMISSION_DENIED);
+        }
+
+        return new ListenerOperation<>() {
+
+            @Override
+            public void operate(IScanListener listener) throws Exception {
+                switch (result) {
+                    case DiscoveryResult.DEVICE_DISCOVERED:
+                        listener.onDiscovered(device);
+                        break;
+                    case DiscoveryResult.DEVICE_UPDATED:
+                        listener.onUpdated(device);
+                        break;
+                    case DiscoveryResult.DEVICE_LOST:
+                        listener.onLost(device);
+                        break;
+                }
+            }
+
+            @Override
+            public void onComplete(boolean success) {
+                if (success) {
+                    if (successReportCallback != null) {
+                        successReportCallback.run();
+                        Log.d(TAG, "Successfully delivered result to caller.");
+                    }
+                }
+            }
+        };
+    }
+
+    private boolean filterCheck(NearbyDeviceParcelable device) {
+        if (device.getScanType() != SCAN_TYPE_NEARBY_PRESENCE) {
+            return true;
+        }
+        List<ScanFilter> presenceFilters = mScanRequest.getScanFilters().stream().filter(
+                scanFilter -> scanFilter.getType() == SCAN_TYPE_NEARBY_PRESENCE).collect(
+                Collectors.toList());
+        return presenceFilterMatches(device, presenceFilters);
+    }
+
+    private boolean checkIdentity() {
+        boolean result = DiscoveryPermissions.noteDiscoveryResultDelivery(mAppOpsManager,
+                mCallerIdentity);
+        Log.v(TAG, "checkIdentity: result is " + result + " mAppOpsManager " + mAppOpsManager);
+        if (!result) {
+            Log.w(TAG, "[DiscoveryProviderManager] scan permission revoked "
+                    + "- not forwarding results for the registration.");
+        }
+        return result;
+    }
+
+    @GuardedBy("mMultiplexerLock")
+    private void scheduleOnLostAlarm(NearbyDeviceParcelable device) {
+        long deviceId = device.getDeviceId();
+        DeviceOnLostAlarm alarm = mDiscoveryOnLostAlarmPerDevice.get(deviceId);
+        if (alarm == null) {
+            alarm = new DeviceOnLostAlarm(device, mAlarmExecutor);
+            mDiscoveryOnLostAlarmPerDevice.put(deviceId, alarm);
+        }
+        alarm.start();
+        Log.d(TAG, "DiscoveryProviderManager updated state for " + device.getDeviceId());
+    }
+
+    /** Status of the discovery result. */
+    @IntDef({DiscoveryResult.DEVICE_DISCOVERED, DiscoveryResult.DEVICE_UPDATED,
+            DiscoveryResult.DEVICE_LOST})
+    public @interface DiscoveryResult {
+        int DEVICE_DISCOVERED = 0;
+        int DEVICE_UPDATED = 1;
+        int DEVICE_LOST = 2;
+    }
+
+    private class DeviceOnLostAlarm {
+
+        private static final String NAME = "DeviceOnLostAlarm";
+        private final NearbyDeviceParcelable mDevice;
+        private final ScheduledExecutorService mAlarmExecutor;
+        @Nullable
+        private CancelableAlarm mTimeoutAlarm;
+
+        DeviceOnLostAlarm(NearbyDeviceParcelable device, ScheduledExecutorService alarmExecutor) {
+            mDevice = device;
+            mAlarmExecutor = alarmExecutor;
+        }
+
+        synchronized void start() {
+            cancel();
+            this.mTimeoutAlarm = CancelableAlarm.createSingleAlarm(NAME, () -> {
+                Log.d(TAG, String.format("%s timed out after %d ms. Reporting %s on lost.", NAME,
+                        ON_LOST_TIME_OUT_MS, mDevice.getName()));
+                synchronized (mMultiplexerLock) {
+                    executeOperation(reportDeviceLost(mDevice));
+                }
+            }, ON_LOST_TIME_OUT_MS, mAlarmExecutor);
+        }
+
+        synchronized void cancel() {
+            if (mTimeoutAlarm != null) {
+                mTimeoutAlarm.cancel();
+                mTimeoutAlarm = null;
+            }
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/common/CancelableAlarmTest.java b/nearby/tests/unit/src/com/android/server/nearby/common/CancelableAlarmTest.java
index c7d3e25..719e816 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/common/CancelableAlarmTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/common/CancelableAlarmTest.java
@@ -80,14 +80,14 @@
     @Test
     public void cancelOfRunAlarmReturnsFalse() throws InterruptedException {
         TestCountDownLatch latch = new TestCountDownLatch(1);
-        long delayMillis = 1000;
+        long delayMillis = 500;
         CancelableAlarm alarm =
                 CancelableAlarm.createSingleAlarm(
                         "cancelOfRunAlarmReturnsFalse",
                         new CountDownRunnable(latch),
                         delayMillis,
                         mExecutor);
-        latch.awaitAndExpectDelay(delayMillis);
+        latch.awaitAndExpectDelay(delayMillis - 1);
 
         assertThat(alarm.cancel()).isFalse();
     }
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/ListenerMultiplexerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/ListenerMultiplexerTest.java
new file mode 100644
index 0000000..104d762
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/ListenerMultiplexerTest.java
@@ -0,0 +1,302 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.os.IBinder;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.nearby.managers.registration.BinderListenerRegistration;
+
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collection;
+
+public class ListenerMultiplexerTest {
+
+    @Before
+    public void setUp() {
+        initMocks(this);
+    }
+
+    @Test
+    public void testAdd() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        multiplexer.addListener(binder, listener, value);
+
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        Runnable listener2 = mock(Runnable.class);
+        IBinder binder2 = mock(IBinder.class);
+        int value2 = 1;
+        multiplexer.addListener(binder2, listener2, value2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(2);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+    }
+
+    @Test
+    public void testReplace() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        multiplexer.addListener(binder, listener, value);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        reset(listener);
+
+        // Same key, different value
+        Runnable listener2 = mock(Runnable.class);
+        int value2 = 1;
+        multiplexer.addListener(binder, listener2, value2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            // Should not be called again
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mOnUnregisterCalledCount).isEqualTo(0);
+            assertThat(multiplexer.mMerged).isEqualTo(value2);
+        }
+        // Run on the new listener
+        multiplexer.notifyListeners();
+        verify(listener, never()).run();
+        verify(listener2, times(1)).run();
+
+        multiplexer.removeRegistration(binder);
+
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isFalse();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mOnUnregisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+    }
+
+    @Test
+    public void testRemove() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        multiplexer.addListener(binder, listener, value);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        reset(listener);
+
+        multiplexer.removeRegistration(binder);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isFalse();
+            assertThat(multiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, never()).run();
+    }
+
+    @Test
+    public void testMergeMultiple() {
+        TestMultiplexer multiplexer = new TestMultiplexer();
+
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+
+        Runnable listener2 = mock(Runnable.class);
+        IBinder binder2 = mock(IBinder.class);
+        int value2 = 1;
+
+        Runnable listener3 = mock(Runnable.class);
+        IBinder binder3 = mock(IBinder.class);
+        int value3 = 5;
+
+        multiplexer.addListener(binder, listener, value);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        verify(listener2, never()).run();
+        verify(listener3, never()).run();
+
+        multiplexer.addListener(binder2, listener2, value2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(2);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(1);
+            assertThat(multiplexer.mMerged).isEqualTo(value);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(2)).run();
+        verify(listener2, times(1)).run();
+        verify(listener3, never()).run();
+
+        multiplexer.addListener(binder3, listener3, value3);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(3);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(2);
+            assertThat(multiplexer.mMerged).isEqualTo(value3);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(2)).run();
+        verify(listener3, times(1)).run();
+
+        multiplexer.removeRegistration(binder);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(4);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(2);
+            assertThat(multiplexer.mMerged).isEqualTo(value3);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(3)).run();
+        verify(listener3, times(2)).run();
+
+        multiplexer.removeRegistration(binder3);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isTrue();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(5);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(3);
+            assertThat(multiplexer.mMerged).isEqualTo(value2);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(4)).run();
+        verify(listener3, times(2)).run();
+
+        multiplexer.removeRegistration(binder2);
+        synchronized (multiplexer.mMultiplexerLock) {
+            assertThat(multiplexer.mRegistered).isFalse();
+            assertThat(multiplexer.mOnRegisterCalledCount).isEqualTo(1);
+            assertThat(multiplexer.mMergeOperationCount).isEqualTo(6);
+            assertThat(multiplexer.mMergeUpdatedCount).isEqualTo(4);
+            assertThat(multiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+        multiplexer.notifyListeners();
+        verify(listener, times(3)).run();
+        verify(listener2, times(4)).run();
+        verify(listener3, times(2)).run();
+    }
+
+    private class TestMultiplexer extends
+            ListenerMultiplexer<Runnable, TestMultiplexer.TestListenerRegistration, Integer> {
+        int mOnRegisterCalledCount;
+        int mOnUnregisterCalledCount;
+        boolean mRegistered;
+        private int mMergeOperationCount;
+        private int mMergeUpdatedCount;
+
+        @Override
+        public void onRegister() {
+            mOnRegisterCalledCount++;
+            mRegistered = true;
+        }
+
+        @Override
+        public void onUnregister() {
+            mOnUnregisterCalledCount++;
+            mRegistered = false;
+        }
+
+        @Override
+        public Integer mergeRegistrations(
+                @NonNull Collection<TestListenerRegistration> testListenerRegistrations) {
+            mMergeOperationCount++;
+            int max = Integer.MIN_VALUE;
+            for (TestListenerRegistration registration : testListenerRegistrations) {
+                max = Math.max(max, registration.getValue());
+            }
+            return max;
+        }
+
+        @Override
+        public void onMergedRegistrationsUpdated() {
+            mMergeUpdatedCount++;
+        }
+
+        public void addListener(IBinder binder, Runnable runnable, int value) {
+            TestListenerRegistration registration = new TestListenerRegistration(binder, runnable,
+                    value);
+            putRegistration(binder, registration);
+        }
+
+        public void notifyListeners() {
+            deliverToListeners(registration -> Runnable::run);
+        }
+
+        private class TestListenerRegistration extends BinderListenerRegistration<Runnable> {
+            private final int mValue;
+
+            protected TestListenerRegistration(IBinder binder, Runnable runnable, int value) {
+                super(binder, MoreExecutors.directExecutor(), runnable);
+                mValue = value;
+            }
+
+            @Override
+            public TestMultiplexer getOwner() {
+                return TestMultiplexer.this;
+            }
+
+            public int getValue() {
+                return mValue;
+            }
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/MergedDiscoveryRequestTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/MergedDiscoveryRequestTest.java
new file mode 100644
index 0000000..9281e42
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/MergedDiscoveryRequestTest.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers;
+
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.nearby.DataElement;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.util.ArraySet;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Unit test for {@link MergedDiscoveryRequest} class.
+ */
+public class MergedDiscoveryRequestTest {
+
+    @Test
+    public void test_addScanType() {
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addScanType(ScanRequest.SCAN_TYPE_FAST_PAIR);
+        builder.addScanType(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE);
+        MergedDiscoveryRequest request = builder.build();
+
+        assertThat(request.getScanTypes()).isEqualTo(new ArraySet<>(
+                Arrays.asList(ScanRequest.SCAN_TYPE_FAST_PAIR,
+                        ScanRequest.SCAN_TYPE_NEARBY_PRESENCE)));
+    }
+
+    @Test
+    public void test_addActions() {
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addActions(new ArrayList<>(Arrays.asList(1, 2, 3)));
+        builder.addActions(new ArraySet<>(Arrays.asList(2, 3, 4)));
+        builder.addActions(new ArraySet<>(Collections.singletonList(5)));
+
+        MergedDiscoveryRequest request = builder.build();
+        assertThat(request.getActions()).isEqualTo(new ArraySet<>(new Integer[]{1, 2, 3, 4, 5}));
+    }
+
+    @Test
+    public void test_addFilters() {
+        final int rssi = -40;
+        final int action = 123;
+        final byte[] secreteId = new byte[]{1, 2, 3, 4};
+        final byte[] authenticityKey = new byte[]{0, 1, 1, 1};
+        final byte[] publicKey = new byte[]{1, 1, 2, 2};
+        final byte[] encryptedMetadata = new byte[]{1, 2, 3, 4, 5};
+        final byte[] metadataEncryptionKeyTag = new byte[]{1, 1, 3, 4, 5};
+        final int key = 3;
+        final byte[] value = new byte[]{1, 1, 1, 1};
+
+        PublicCredential mPublicCredential = new PublicCredential.Builder(secreteId,
+                authenticityKey, publicKey, encryptedMetadata,
+                metadataEncryptionKeyTag).setIdentityType(IDENTITY_TYPE_PRIVATE).build();
+        PresenceScanFilter scanFilterBuilder = new PresenceScanFilter.Builder().setMaxPathLoss(
+                rssi).addCredential(mPublicCredential).addPresenceAction(
+                action).addExtendedProperty(new DataElement(key, value)).build();
+
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addScanFilters(Collections.singleton(scanFilterBuilder));
+        MergedDiscoveryRequest request = builder.build();
+
+        Set<ScanFilter> expectedResult = new ArraySet<>();
+        expectedResult.add(scanFilterBuilder);
+        assertThat(request.getScanFilters()).isEqualTo(expectedResult);
+    }
+
+    @Test
+    public void test_addMedium() {
+        MergedDiscoveryRequest.Builder builder = new MergedDiscoveryRequest.Builder();
+        builder.addMedium(MergedDiscoveryRequest.Medium.BLE);
+        builder.addMedium(MergedDiscoveryRequest.Medium.BLE);
+        MergedDiscoveryRequest request = builder.build();
+
+        Set<Integer> expectedResult = new ArraySet<>();
+        expectedResult.add(MergedDiscoveryRequest.Medium.BLE);
+        assertThat(request.getMediums()).isEqualTo(expectedResult);
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/registration/BinderListenerRegistrationTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/BinderListenerRegistrationTest.java
new file mode 100644
index 0000000..8814190
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/BinderListenerRegistrationTest.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers.registration;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.os.IBinder;
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.nearby.managers.ListenerMultiplexer;
+
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collection;
+
+/**
+ * Unit test for {@link BinderListenerRegistration} class.
+ */
+public class BinderListenerRegistrationTest {
+    private TestMultiplexer mMultiplexer;
+    private boolean mOnRegisterCalled;
+    private boolean mOnUnRegisterCalled;
+
+    @Before
+    public void setUp() {
+        mMultiplexer = new TestMultiplexer();
+    }
+
+    @Test
+    public void test_addAndRemove() throws RemoteException {
+        Runnable listener = mock(Runnable.class);
+        IBinder binder = mock(IBinder.class);
+        int value = 2;
+        BinderListenerRegistration<Runnable> registration = mMultiplexer.addListener(binder,
+                listener, value);
+        // First element, onRegister should be called
+        assertThat(mOnRegisterCalled).isTrue();
+        verify(binder, times(1)).linkToDeath(any(), anyInt());
+        mMultiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(value);
+        }
+        reset(listener);
+
+        Runnable listener2 = mock(Runnable.class);
+        IBinder binder2 = mock(IBinder.class);
+        int value2 = 1;
+        BinderListenerRegistration<Runnable> registration2 = mMultiplexer.addListener(binder2,
+                listener2, value2);
+        verify(binder2, times(1)).linkToDeath(any(), anyInt());
+        mMultiplexer.notifyListeners();
+        verify(listener2, times(1)).run();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(value);
+        }
+        reset(listener);
+        reset(listener2);
+
+        registration2.remove();
+        verify(binder2, times(1)).unlinkToDeath(any(), anyInt());
+        // Remove one element, onUnregister should NOT be called
+        assertThat(mOnUnRegisterCalled).isFalse();
+        mMultiplexer.notifyListeners();
+        verify(listener, times(1)).run();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(value);
+        }
+        reset(listener);
+        reset(listener2);
+
+        registration.remove();
+        verify(binder, times(1)).unlinkToDeath(any(), anyInt());
+        // Remove all elements, onUnregister should NOT be called
+        assertThat(mOnUnRegisterCalled).isTrue();
+        synchronized (mMultiplexer.mMultiplexerLock) {
+            assertThat(mMultiplexer.mMerged).isEqualTo(Integer.MIN_VALUE);
+        }
+    }
+
+    private class TestMultiplexer extends
+            ListenerMultiplexer<Runnable, TestMultiplexer.TestListenerRegistration, Integer> {
+        @Override
+        public void onRegister() {
+            mOnRegisterCalled = true;
+        }
+
+        @Override
+        public void onUnregister() {
+            mOnUnRegisterCalled = true;
+        }
+
+        @Override
+        public Integer mergeRegistrations(
+                @NonNull Collection<TestListenerRegistration> testListenerRegistrations) {
+            int max = Integer.MIN_VALUE;
+            for (TestListenerRegistration registration : testListenerRegistrations) {
+                max = Math.max(max, registration.getValue());
+            }
+            return max;
+        }
+
+        @Override
+        public void onMergedRegistrationsUpdated() {
+        }
+
+        public BinderListenerRegistration<Runnable> addListener(IBinder binder, Runnable runnable,
+                int value) {
+            TestListenerRegistration registration = new TestListenerRegistration(binder, runnable,
+                    value);
+            putRegistration(binder, registration);
+            return registration;
+        }
+
+        public void notifyListeners() {
+            deliverToListeners(registration -> Runnable::run);
+        }
+
+        private class TestListenerRegistration extends BinderListenerRegistration<Runnable> {
+            private final int mValue;
+
+            protected TestListenerRegistration(IBinder binder, Runnable runnable, int value) {
+                super(binder, MoreExecutors.directExecutor(), runnable);
+                mValue = value;
+            }
+
+            @Override
+            public TestMultiplexer getOwner() {
+                return TestMultiplexer.this;
+            }
+
+            public int getValue() {
+                return mValue;
+            }
+        }
+    }
+}
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/registration/DiscoveryRegistrationTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/DiscoveryRegistrationTest.java
new file mode 100644
index 0000000..03c4f75
--- /dev/null
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/registration/DiscoveryRegistrationTest.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.managers.registration;
+
+import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.app.AppOpsManager;
+import android.nearby.DataElement;
+import android.nearby.IScanListener;
+import android.nearby.NearbyDeviceParcelable;
+import android.nearby.PresenceScanFilter;
+import android.nearby.PublicCredential;
+import android.nearby.ScanCallback;
+import android.nearby.ScanFilter;
+import android.nearby.ScanRequest;
+import android.os.IBinder;
+import android.util.ArraySet;
+
+import androidx.annotation.NonNull;
+
+import com.android.server.nearby.managers.ListenerMultiplexer;
+import com.android.server.nearby.managers.MergedDiscoveryRequest;
+import com.android.server.nearby.util.identity.CallerIdentity;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executors;
+
+/**
+ * Unit test for {@link DiscoveryRegistration} class.
+ */
+public class DiscoveryRegistrationTest {
+    private static final int RSSI = -40;
+    private static final int ACTION = 123;
+    private static final byte[] SECRETE_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] AUTHENTICITY_KEY = new byte[]{0, 1, 1, 1};
+    private static final byte[] PUBLIC_KEY = new byte[]{1, 1, 2, 2};
+    private static final byte[] ENCRYPTED_METADATA = new byte[]{1, 2, 3, 4, 5};
+    private static final byte[] METADATA_ENCRYPTION_KEY_TAG = new byte[]{1, 1, 3, 4, 5};
+    private static final int KEY = 3;
+    private static final byte[] VALUE = new byte[]{1, 1, 1, 1};
+    private final PublicCredential mPublicCredential = new PublicCredential.Builder(SECRETE_ID,
+            AUTHENTICITY_KEY, PUBLIC_KEY, ENCRYPTED_METADATA,
+            METADATA_ENCRYPTION_KEY_TAG).setIdentityType(IDENTITY_TYPE_PRIVATE).build();
+    private final PresenceScanFilter mFilter = new PresenceScanFilter.Builder().setMaxPathLoss(
+            50).addCredential(mPublicCredential).addPresenceAction(ACTION).addExtendedProperty(
+            new DataElement(KEY, VALUE)).build();
+    private DiscoveryRegistration mDiscoveryRegistration;
+    private ScanRequest mScanRequest;
+    private TestDiscoveryManager mOwner;
+    private Object mMultiplexLock;
+    @Mock
+    private IScanListener mCallback;
+    @Mock
+    private CallerIdentity mIdentity;
+    @Mock
+    private AppOpsManager mAppOpsManager;
+    @Mock
+    private IBinder mBinder;
+
+    @Before
+    public void setUp() {
+        initMocks(this);
+        when(mCallback.asBinder()).thenReturn(mBinder);
+        when(mAppOpsManager.noteOp(eq("android:bluetooth_scan"), eq(0), eq(null), eq(null),
+                eq(null))).thenReturn(AppOpsManager.MODE_ALLOWED);
+
+        mOwner = new TestDiscoveryManager();
+        mMultiplexLock = new Object();
+        mScanRequest = new ScanRequest.Builder().setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).addScanFilter(mFilter).build();
+        mDiscoveryRegistration = new DiscoveryRegistration(mOwner, mScanRequest, mCallback,
+                Executors.newSingleThreadExecutor(), mIdentity, mMultiplexLock, mAppOpsManager);
+    }
+
+    @Test
+    public void test_getScanRequest() {
+        assertThat(mDiscoveryRegistration.getScanRequest()).isEqualTo(mScanRequest);
+    }
+
+    @Test
+    public void test_getActions() {
+        Set<Integer> result = new ArraySet<>();
+        result.add(ACTION);
+        assertThat(mDiscoveryRegistration.getActions()).isEqualTo(result);
+    }
+
+    @Test
+    public void test_getOwner() {
+        assertThat(mDiscoveryRegistration.getOwner()).isEqualTo(mOwner);
+    }
+
+    @Test
+    public void test_getPresenceScanFilters() {
+        Set<ScanFilter> result = new ArraySet<>();
+        result.add(mFilter);
+        assertThat(mDiscoveryRegistration.getPresenceScanFilters()).isEqualTo(result);
+    }
+
+    @Test
+    public void test_presenceFilterMatches_match() {
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                123).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        assertThat(DiscoveryRegistration.presenceFilterMatches(device, List.of(mFilter))).isTrue();
+    }
+
+    @Test
+    public void test_presenceFilterMatches_emptyFilter() {
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                123).setName("test").setScanType(ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).build();
+        assertThat(DiscoveryRegistration.presenceFilterMatches(device, List.of())).isTrue();
+    }
+
+    @Test
+    public void test_presenceFilterMatches_actionNotMatch() {
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                12).setName("test").setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(5).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        assertThat(DiscoveryRegistration.presenceFilterMatches(device, List.of(mFilter))).isFalse();
+    }
+
+    @Test
+    public void test_onDiscoveredOnUpdatedCalled() throws Exception {
+        final long deviceId = 122;
+        NearbyDeviceParcelable.Builder builder = new NearbyDeviceParcelable.Builder().setDeviceId(
+                deviceId).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG);
+        runOperation(mDiscoveryRegistration.onNearbyDeviceDiscovered(builder.build()));
+
+        verify(mCallback, times(1)).onDiscovered(eq(builder.build()));
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onLost(any());
+        verify(mCallback, never()).onError(anyInt());
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNotNull();
+        reset(mCallback);
+
+        // Update RSSI
+        runOperation(
+                mDiscoveryRegistration.onNearbyDeviceDiscovered(builder.setRssi(RSSI - 1).build()));
+        verify(mCallback, never()).onDiscovered(any());
+        verify(mCallback, times(1)).onUpdated(eq(builder.build()));
+        verify(mCallback, never()).onLost(any());
+        verify(mCallback, never()).onError(anyInt());
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNotNull();
+    }
+
+    @Test
+    public void test_onLost() throws Exception {
+        final long deviceId = 123;
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                deviceId).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        runOperation(mDiscoveryRegistration.onNearbyDeviceDiscovered(device));
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNotNull();
+        verify(mCallback, times(1)).onDiscovered(eq(device));
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onError(anyInt());
+        verify(mCallback, never()).onLost(any());
+        reset(mCallback);
+
+        runOperation(mDiscoveryRegistration.reportDeviceLost(device));
+
+        assertThat(mDiscoveryRegistration.getDiscoveryOnLostAlarms().get(deviceId)).isNull();
+        verify(mCallback, never()).onDiscovered(eq(device));
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onError(anyInt());
+        verify(mCallback, times(1)).onLost(eq(device));
+    }
+
+    @Test
+    public void test_onError() throws Exception {
+        AppOpsManager manager = mock(AppOpsManager.class);
+        when(manager.noteOp(eq("android:bluetooth_scan"), eq(0), eq(null), eq(null),
+                eq(null))).thenReturn(AppOpsManager.MODE_IGNORED);
+
+        DiscoveryRegistration r = new DiscoveryRegistration(mOwner, mScanRequest, mCallback,
+                Executors.newSingleThreadExecutor(), mIdentity, mMultiplexLock, manager);
+
+        NearbyDeviceParcelable device = new NearbyDeviceParcelable.Builder().setDeviceId(
+                123).setName("test").setTxPower(RSSI + 1).setRssi(RSSI).setScanType(
+                ScanRequest.SCAN_TYPE_NEARBY_PRESENCE).setAction(ACTION).setEncryptionKeyTag(
+                METADATA_ENCRYPTION_KEY_TAG).build();
+        runOperation(r.onNearbyDeviceDiscovered(device));
+
+        verify(mCallback, never()).onDiscovered(any());
+        verify(mCallback, never()).onUpdated(any());
+        verify(mCallback, never()).onLost(any());
+        verify(mCallback, times(1)).onError(eq(ScanCallback.ERROR_PERMISSION_DENIED));
+    }
+
+    private void runOperation(BinderListenerRegistration.ListenerOperation<IScanListener> operation)
+            throws Exception {
+        if (operation == null) {
+            return;
+        }
+        operation.onScheduled(false);
+        operation.operate(mCallback);
+        operation.onComplete(/* success= */ true);
+    }
+
+    private static class TestDiscoveryManager extends
+            ListenerMultiplexer<IScanListener, DiscoveryRegistration, MergedDiscoveryRequest> {
+
+        @Override
+        public MergedDiscoveryRequest mergeRegistrations(
+                @NonNull Collection<DiscoveryRegistration> discoveryRegistrations) {
+            return null;
+        }
+
+        @Override
+        public void onMergedRegistrationsUpdated() {
+
+        }
+    }
+}