Temporary disconnects for Bluetooth profiles.

Allow apps to request that a Bluetooth profile be temporarily
disconnected. That profile will remain disconnected until the request is
lifted, or until the requesting process dies. Multiple apps may make
requests, and the profile will stay disconnected until all such requests
are removed or have their processes die.

In the event of an unexpected crash or user switch, the disconnected
profiles will be persisted to Settings.Secure. When that user starts
again, the requests will be reverted and the profile will be re-enabled.

Test: Manual tests (request, release, app crash, user switch, reboot).
Bug: 116226107
Change-Id: I67883374f36aae6154e03d31756a75552e2c6faf
Fixes: 116226107
diff --git a/car-lib/src/android/car/CarBluetoothManager.java b/car-lib/src/android/car/CarBluetoothManager.java
index 841a055..bb9c8ff 100644
--- a/car-lib/src/android/car/CarBluetoothManager.java
+++ b/car-lib/src/android/car/CarBluetoothManager.java
@@ -19,10 +19,6 @@
 import android.annotation.IntDef;
 import android.annotation.RequiresPermission;
 import android.bluetooth.BluetoothDevice;
-import android.car.CarLibLog;
-import android.car.CarManagerBase;
-import android.car.CarNotConnectedException;
-import android.car.ICarBluetooth;
 import android.content.Context;
 import android.os.IBinder;
 import android.os.RemoteException;
@@ -132,6 +128,49 @@
         }
     }
 
+    /**
+     * Request to disconnect the given profile on the given device, and prevent it from reconnecting
+     * until either the request is released, or the process owning the given token dies.
+     *
+     * @param device The device on which to disconnect a profile.
+     * @param profile The {@link android.bluetooth.BluetoothProfile} to disconnect.
+     * @param token A {@link IBinder} to be used as an identity for the request. If the process
+     *     owning the token dies, the request will automatically be released.
+     * @return True if the profile was successfully disconnected, false if an error occurred.
+     */
+    @RequiresPermission(Manifest.permission.BLUETOOTH_ADMIN)
+    public boolean requestTemporaryProfileDisconnect(
+            BluetoothDevice device, int profile, IBinder token) throws CarNotConnectedException {
+        try {
+            return mService.requestTemporaryDisconnect(device, profile, token);
+        } catch (RemoteException e) {
+            Log.e(CarLibLog.TAG_CAR, "requestTemporaryDisconnect failed", e);
+            throw new CarNotConnectedException(e);
+        }
+    }
+
+    /**
+     * Undo a previous call to {@link #requestTemporaryProfileDisconnect} with the same parameters,
+     * and reconnect the profile if no other requests are active.
+     *
+     * @param device The device on which to release the disconnect request.
+     * @param profile The profile on which to release the disconnect request.
+     * @param token The token provided in the original call to
+     *              {@link #requestTemporaryProfileDisconnect}.
+     *
+     * @return True if the request was released, false if an error occurred.
+     */
+    @RequiresPermission(Manifest.permission.BLUETOOTH_ADMIN)
+    public boolean releaseTemporaryProfileDisconnect(
+            BluetoothDevice device, int profile, IBinder token) throws CarNotConnectedException {
+        try {
+            return mService.releaseTemporaryDisconnect(device, profile, token);
+        } catch (RemoteException e) {
+            Log.e(CarLibLog.TAG_CAR, "requestTemporaryDisconnect failed", e);
+            throw new CarNotConnectedException(e);
+        }
+    }
+
     /** @hide */
     public CarBluetoothManager(IBinder service, Context context) {
         mContext = context;
diff --git a/car-lib/src/android/car/ICarBluetooth.aidl b/car-lib/src/android/car/ICarBluetooth.aidl
index a5fbb73..ee7eb29 100644
--- a/car-lib/src/android/car/ICarBluetooth.aidl
+++ b/car-lib/src/android/car/ICarBluetooth.aidl
@@ -24,4 +24,6 @@
     void clearBluetoothDeviceConnectionPriority(in int profileToClear,in int priorityToClear);
     boolean isPriorityDevicePresent(in int profile, in int priorityToCheck);
     String getDeviceNameWithPriority(in int profile, in int priorityToCheck);
+    boolean requestTemporaryDisconnect(in BluetoothDevice device, in int profile, in IBinder token);
+    boolean releaseTemporaryDisconnect(in BluetoothDevice device, in int profile, in IBinder token);
 }
diff --git a/car-lib/src/android/car/ICarBluetoothUserService.aidl b/car-lib/src/android/car/ICarBluetoothUserService.aidl
index a906a3c..d69d08f 100644
--- a/car-lib/src/android/car/ICarBluetoothUserService.aidl
+++ b/car-lib/src/android/car/ICarBluetoothUserService.aidl
@@ -24,5 +24,7 @@
     void closeBluetoothConnectionProxy();
     boolean isBluetoothConnectionProxyAvailable(in int profile);
     void bluetoothConnectToProfile(in int profile, in BluetoothDevice device);
+    void bluetoothDisconnectFromProfile(in int profile, in BluetoothDevice device);
+    int getProfilePriority(in int profile, in BluetoothDevice device);
     void setProfilePriority(in int profile, in BluetoothDevice device, in int priority);
 }
diff --git a/car-lib/src/android/car/settings/CarSettings.java b/car-lib/src/android/car/settings/CarSettings.java
index abbefc1..c3a2657 100644
--- a/car-lib/src/android/car/settings/CarSettings.java
+++ b/car-lib/src/android/car/settings/CarSettings.java
@@ -193,6 +193,12 @@
         public static final String KEY_BLUETOOTH_AUTOCONNECT_NETWORK_DEVICE_PRIORITY_1 =
                 "android.car.BLUETOOTH_AUTOCONNECT_NETWORK_DEVICE_PRIORITY_1";
 
-
+        /**
+         * Key for storing temporarily-disconnected devices and profiles.
+         * Read and written by {@link com.android.car.BluetoothDeviceConnectionPolicy}.
+         * @hide
+         */
+        public static final String KEY_BLUETOOTH_TEMPORARY_DISCONNECTS =
+                "android.car.BLUETOOTH_TEMPORARY_DISCONNECTS";
     }
 }
diff --git a/service/src/com/android/car/BluetoothDeviceConnectionPolicy.java b/service/src/com/android/car/BluetoothDeviceConnectionPolicy.java
index 5fcbbcd..dcd7e16 100644
--- a/service/src/com/android/car/BluetoothDeviceConnectionPolicy.java
+++ b/service/src/com/android/car/BluetoothDeviceConnectionPolicy.java
@@ -20,6 +20,7 @@
 import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_MUSIC_DEVICES;
 import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_NETWORK_DEVICES;
 import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_PHONE_DEVICES;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_TEMPORARY_DISCONNECTS;
 
 import android.annotation.Nullable;
 import android.app.ActivityManager;
@@ -46,11 +47,16 @@
 import android.content.IntentFilter;
 import android.hardware.automotive.vehicle.V2_0.VehicleIgnitionState;
 import android.hardware.automotive.vehicle.V2_0.VehicleProperty;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
 import android.os.ParcelUuid;
 import android.os.Parcelable;
 import android.os.RemoteException;
 import android.os.UserHandle;
 import android.provider.Settings;
+import android.text.TextUtils;
 import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -65,6 +71,7 @@
 import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
 
 
 /**
@@ -92,6 +99,10 @@
     private static final String TAG = "BTDevConnectionPolicy";
     private static final String SETTINGS_DELIMITER = ",";
     private static final boolean DBG = Utils.DBG;
+
+    private static final Binder RESTORED_TEMPORARY_DISCONNECT_TOKEN = new Binder();
+    private static final long RESTORE_BACKOFF_MILLIS = 1000L;
+
     private final Context mContext;
     private boolean mInitialized = false;
     private boolean mUserSpecificInfoInitialized = false;
@@ -147,6 +158,13 @@
     // Maintain a list of Paired devices which haven't connected on any profiles yet.
     private Set<BluetoothDevice> mPairedButUnconnectedDevices = new HashSet<>();
 
+    // State for temporary disconnects. Guarded by lock on `this`.
+    private final SetMultimap<ConnectionParams, DisconnectRecord> mTemporaryDisconnects;
+    private final HashSet<DisconnectRecord> mRestoredDisconnects = new HashSet<>();
+    private final HashSet<ConnectionParams> mAlreadyDisabledProfiles = new HashSet<>();
+
+    private final Handler mHandler = new Handler(Looper.getMainLooper());
+
     public static BluetoothDeviceConnectionPolicy create(Context context,
             CarPropertyService carPropertyService, PerUserCarServiceHelper userServiceHelper,
             CarUxRestrictionsManagerService uxrService, CarBluetoothService bluetoothService) {
@@ -209,6 +227,8 @@
             Log.w(TAG, "No Bluetooth Adapter Available");
         }
         mFastPairProvider = new FastPairProvider(mContext);
+
+        mTemporaryDisconnects = new SetMultimap<>();
     }
 
     /**
@@ -219,26 +239,35 @@
      * Used as the currency that methods use to talk to each other in the policy.
      */
     public static class ConnectionParams {
-        private final BluetoothDevice mBluetoothDevice;
-        private final Integer mBluetoothProfile;
+        // Examples:
+        // 01:23:45:67:89:AB/9
+        // null/0
+        // null/null
+        private static final String FLATTENED_PATTERN =
+                "^(([0-9A-F]{2}:){5}[0-9A-F]{2}|null)/([0-9]+|null)$";
+
+        @Nullable private final BluetoothDevice mBluetoothDevice;
+        @Nullable private final Integer mBluetoothProfile;
 
         public ConnectionParams() {
             this(null, null);
         }
 
-        public ConnectionParams(Integer profile) {
+        public ConnectionParams(@Nullable Integer profile) {
             this(profile, null);
         }
 
-        public ConnectionParams(Integer profile, BluetoothDevice device) {
+        public ConnectionParams(@Nullable Integer profile, @Nullable BluetoothDevice device) {
             mBluetoothProfile = profile;
             mBluetoothDevice = device;
         }
 
+        @Nullable
         public BluetoothDevice getBluetoothDevice() {
             return mBluetoothDevice;
         }
 
+        @Nullable
         public Integer getBluetoothProfile() {
             return mBluetoothProfile;
         }
@@ -260,6 +289,92 @@
         public int hashCode() {
             return Objects.hash(mBluetoothDevice, mBluetoothProfile);
         }
+
+        @Override
+        public String toString() {
+            return flattenToString();
+        }
+
+        /** Converts these {@link ConnectionParams} to a parseable string representation. */
+        public String flattenToString() {
+            return mBluetoothDevice + "/" + mBluetoothProfile;
+        }
+
+        /**
+         * Creates a {@link ConnectionParams} from a previous output of {@link #flattenToString()}.
+         *
+         * @param flattenedParams A flattened string representation of a {@link ConnectionParams}.
+         * @param adapter A {@link BluetoothAdapter} used to convert Bluetooth addresses into
+         *         {@link BluetoothDevice} objects.
+         */
+        public static ConnectionParams parse(String flattenedParams, BluetoothAdapter adapter) {
+            if (!flattenedParams.matches(FLATTENED_PATTERN)) {
+                throw new IllegalArgumentException("Bad format for flattened ConnectionParams");
+            }
+            String[] parts = flattenedParams.split("/");
+
+            BluetoothDevice device;
+            if (!"null".equals(parts[0])) {
+                device = adapter.getRemoteDevice(parts[0]);
+            } else {
+                device = null;
+            }
+
+            Integer profile;
+            if (!"null".equals(parts[1])) {
+                profile = Integer.valueOf(parts[1]);
+            } else {
+                profile = null;
+            }
+
+            return new ConnectionParams(profile, device);
+        }
+    }
+
+    private class DisconnectRecord implements IBinder.DeathRecipient {
+        private final ConnectionParams mParams;
+        private final IBinder mToken;
+
+        private boolean mRemoved = false;
+
+        DisconnectRecord(ConnectionParams params, IBinder token) {
+            this.mParams = params;
+            this.mToken = token;
+        }
+
+        public ConnectionParams getParams() {
+            return mParams;
+        }
+
+        public IBinder getToken() {
+            return mToken;
+        }
+
+        public boolean removeSelf() {
+            synchronized (BluetoothDeviceConnectionPolicy.this) {
+                if (mRemoved) {
+                    return true;
+                }
+
+                if (removeDisconnectRecord(this)) {
+                    mRemoved = true;
+                    return true;
+                } else {
+                    return false;
+                }
+            }
+        }
+
+        @Override
+        public void binderDied() {
+            if (DBG) {
+                Log.d(TAG, "Releasing disconnect request on profile "
+                        + Utils.getProfileName(mParams.getBluetoothProfile())
+                        + " for device " + mParams.getBluetoothDevice()
+                        + ": requesting process died");
+            }
+            removeSelf();
+        }
     }
 
     /**
@@ -382,6 +497,19 @@
         }
         if (mCarBluetoothUserService != null) {
             for (Integer profile : mProfilesToConnect) {
+                // If this profile is temporarily disconnected, don't try to change its priority
+                // until the temporary disconnect is released.
+                synchronized (this) {
+                    ConnectionParams params = new ConnectionParams(profile, device);
+                    if (mTemporaryDisconnects.keySet().contains(params)) {
+                        if (DBG) {
+                            Log.i(TAG, "Not setting profile " + profile + " priority of "
+                                    + device.getAddress() + " to " + priority + ": "
+                                    + "temporarily disconnected");
+                        }
+                        continue;
+                    }
+                }
                 setBluetoothProfilePriorityIfUuidFound(uuids, profile, device, priority);
             }
         }
@@ -450,6 +578,10 @@
             mCarBluetoothUserService = setupBluetoothUserService();
             // re-initialize for current user.
             initializeUserSpecificInfo();
+            // Restore temporary disconnects, if any, that were saved from last run...
+            restoreTemporaryDisconnectsFromSettings();
+            // ... and start trying to remove them.
+            removeRestoredTemporaryDisconnects();
         }
 
         @Override
@@ -457,6 +589,15 @@
             if (DBG) {
                 Log.d(TAG, "Before Unbinding from UserService");
             }
+
+            // Try to release temporary disconnects now, before CarBluetoothUserService goes away.
+            // This also stops any active attempts to remove restored disconnects.
+            //
+            // If any can't be released, they'll persist in settings and will be cleaned up
+            // next time this user starts. This can happen if the Bluetooth profile proxies in
+            // CarBluetoothUserService unbind before we get the chance to make calls on them.
+            releaseAllDisconnectRecordsBeforeUnbind();
+
             try {
                 if (mCarBluetoothUserService != null) {
                     mCarBluetoothUserService.closeBluetoothConnectionProxy();
@@ -466,6 +607,7 @@
                         "Remote Exception during closeBluetoothConnectionProxy(): "
                                 + e.getMessage());
             }
+
             // Clean up information related to user who went background.
             cleanupUserSpecificInfo();
         }
@@ -830,6 +972,309 @@
     }
 
     /**
+     * Request to disconnect the given profile on the given device, and prevent it from reconnecting
+     * until either the request is released, or the process owning the given token dies.
+     * @return True if the profile was successfully disconnected, false if an error occurred.
+     */
+    public boolean requestProfileDisconnect(BluetoothDevice device, int profile, IBinder token) {
+        if (DBG) {
+            Log.d(TAG, "Request profile disconnect: profile " + Utils.getProfileName(profile)
+                    + ", device " + device.getAddress());
+        }
+        ConnectionParams params = new ConnectionParams(profile, device);
+        DisconnectRecord record = new DisconnectRecord(params, token);
+        return addDisconnectRecord(record);
+    }
+
+    /**
+     * Undo a previous call to {@link #requestProfileDisconnect} with the same parameters,
+     * and reconnect the profile if no other requests are active.
+     *
+     * @return True if the request was released, false if an error occurred.
+     */
+    public boolean releaseProfileDisconnect(BluetoothDevice device, int profile, IBinder token) {
+        if (DBG) {
+            Log.d(TAG, "Release profile disconnect: profile " + Utils.getProfileName(profile)
+                    + ", device " + device.getAddress());
+        }
+
+        ConnectionParams params = new ConnectionParams(profile, device);
+        DisconnectRecord record;
+        synchronized (this) {
+            record = findDisconnectRecordLocked(params, token);
+        }
+
+        if (record == null) {
+            Log.e(TAG, "Record not found");
+            return false;
+        }
+
+        return record.removeSelf();
+    }
+
+    /** Add a temporary disconnect record, disconnecting if necessary. */
+    private synchronized boolean addDisconnectRecord(DisconnectRecord record) {
+        ConnectionParams params = record.getParams();
+        if (!isProxyAvailable(params.getBluetoothProfile())) {
+            return false;
+        }
+
+        Set<DisconnectRecord> previousRecords = mTemporaryDisconnects.get(params);
+        if (findDisconnectRecordLocked(params, record.getToken()) != null) {
+            Log.e(TAG, "Disconnect request already registered - skipping duplicate");
+            return false;
+        }
+
+        try {
+            record.getToken().linkToDeath(record, 0);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Could not link to death on disconnect token (already dead?)", e);
+            return false;
+        }
+
+        boolean isNewlyAdded = previousRecords.isEmpty();
+        mTemporaryDisconnects.put(params, record);
+
+        if (isNewlyAdded) {
+            try {
+                int priority =
+                        mCarBluetoothUserService.getProfilePriority(
+                                params.getBluetoothProfile(),
+                                params.getBluetoothDevice());
+                if (priority == BluetoothProfile.PRIORITY_OFF) {
+                    // This profile was already disabled (and not as the result of a temporary
+                    // disconnect). Add it to the already-disabled list, and do nothing else.
+                    mAlreadyDisabledProfiles.add(params);
+
+                    if (DBG) {
+                        Log.d(TAG, "Profile " + Utils.getProfileName(params.getBluetoothProfile())
+                                + " already disabled for device " + params.getBluetoothDevice()
+                                + " - suppressing re-enable");
+                    }
+                } else {
+                    mCarBluetoothUserService.setProfilePriority(
+                            params.getBluetoothProfile(),
+                            params.getBluetoothDevice(),
+                            BluetoothProfile.PRIORITY_OFF);
+                    mCarBluetoothUserService.bluetoothDisconnectFromProfile(
+                            params.getBluetoothProfile(),
+                            params.getBluetoothDevice());
+                    if (DBG) {
+                        Log.d(TAG, "Disabled profile "
+                                + Utils.getProfileName(params.getBluetoothProfile())
+                                + " for device " + params.getBluetoothDevice());
+                    }
+                }
+            } catch (RemoteException e) {
+                Log.e(TAG, "Could not disable profile", e);
+                record.getToken().unlinkToDeath(record, 0);
+                mTemporaryDisconnects.remove(params, record);
+                return false;
+            }
+        }
+
+        saveTemporaryDisconnectsToSettingsLocked();
+        return true;
+    }
+
+    /** Remove a given temporary disconnect record, reconnecting if necessary. */
+    private synchronized boolean removeDisconnectRecord(DisconnectRecord record) {
+        ConnectionParams params = record.getParams();
+        if (!isProxyAvailable(params.getBluetoothProfile())) {
+            return false;
+        }
+        if (!mTemporaryDisconnects.containsEntry(params, record)) {
+            Log.e(TAG, "Record already removed");
+            // Removing something a second time vacuously succeeds.
+            return true;
+        }
+
+        // Re-enable profile before unlinking and removing the record, in case of error.
+        // The profile should be re-enabled if this record is the only one left for that
+        // device and profile combination.
+        if (mTemporaryDisconnects.get(params).size() == 1) {
+            if (!restoreProfilePriority(params)) {
+                return false;
+            }
+        }
+
+        record.getToken().unlinkToDeath(record, 0);
+        mTemporaryDisconnects.remove(params, record);
+
+        saveTemporaryDisconnectsToSettingsLocked();
+        return true;
+    }
+
+    /** Find the disconnect record, if any, corresponding to the given parameters and token. */
+    @Nullable
+    private DisconnectRecord findDisconnectRecordLocked(ConnectionParams params, IBinder token) {
+        return mTemporaryDisconnects.get(params)
+            .stream()
+            .filter(r -> r.getToken() == token)
+            .findAny()
+            .orElse(null);
+    }
+
+    /** Re-enable and reconnect a given profile for a device. */
+    private boolean restoreProfilePriority(ConnectionParams params) {
+        if (!isProxyAvailable(params.getBluetoothProfile())) {
+            return false;
+        }
+
+        if (mAlreadyDisabledProfiles.remove(params)) {
+            // The profile does not need any state changes, since it was disabled
+            // before it was temporarily disconnected. Leave it disconnected.
+            if (DBG) {
+                Log.d(TAG, "Not restoring profile "
+                        + Utils.getProfileName(params.getBluetoothProfile()) + " for device "
+                        + params.getBluetoothDevice() + " - was manually disabled");
+            }
+            return true;
+        }
+
+        try {
+            mCarBluetoothUserService.setProfilePriority(
+                    params.getBluetoothProfile(),
+                    params.getBluetoothDevice(),
+                    BluetoothProfile.PRIORITY_ON);
+            mCarBluetoothUserService.bluetoothConnectToProfile(
+                    params.getBluetoothProfile(),
+                    params.getBluetoothDevice());
+            if (DBG) {
+                Log.d(TAG, "Restored profile " + Utils.getProfileName(params.getBluetoothProfile())
+                        + " for device " + params.getBluetoothDevice());
+            }
+            return true;
+        } catch (RemoteException e) {
+            Log.e(TAG, "Could not enable profile", e);
+            return false;
+        }
+    }
+
+    /** Dump all currently-active temporary disconnects to {@link Settings.Secure}. */
+    private void saveTemporaryDisconnectsToSettingsLocked() {
+        Set<ConnectionParams> disconnectedProfiles = new HashSet<>(mTemporaryDisconnects.keySet());
+        // Don't write out profiles that were disconnected before a request was made, since
+        // restoring those profiles is a no-op.
+        disconnectedProfiles.removeAll(mAlreadyDisabledProfiles);
+        String savedDisconnects =
+                disconnectedProfiles
+                        .stream()
+                        .map(ConnectionParams::flattenToString)
+                        .collect(Collectors.joining(SETTINGS_DELIMITER));
+
+        if (DBG) {
+            Log.d(TAG, "Saving disconnects to settings for u" + mUserId + ": " + savedDisconnects);
+        }
+
+        Settings.Secure.putStringForUser(
+                mContext.getContentResolver(), KEY_BLUETOOTH_TEMPORARY_DISCONNECTS,
+                savedDisconnects, mUserId);
+    }
+
+    /** Create {@link DisconnectRecord}s for all temporary disconnects written to settings. */
+    private synchronized void restoreTemporaryDisconnectsFromSettings() {
+        if (mBluetoothAdapter == null) {
+            Log.e(TAG, "Cannot restore disconnect records - Bluetooth not available");
+            return;
+        }
+
+        String savedConnectionParams = Settings.Secure.getStringForUser(
+                mContext.getContentResolver(),
+                KEY_BLUETOOTH_TEMPORARY_DISCONNECTS,
+                mUserId);
+
+        if (TextUtils.isEmpty(savedConnectionParams)) {
+            return;
+        }
+
+        if (DBG) {
+            Log.d(TAG, "Restoring temporary disconnects: " + savedConnectionParams);
+        }
+
+        for (String paramsStr : savedConnectionParams.split(SETTINGS_DELIMITER)) {
+            try {
+                ConnectionParams params = ConnectionParams.parse(paramsStr, mBluetoothAdapter);
+                DisconnectRecord record =
+                        new DisconnectRecord(params, RESTORED_TEMPORARY_DISCONNECT_TOKEN);
+                mTemporaryDisconnects.put(params, record);
+                mRestoredDisconnects.add(record);
+                if (DBG) {
+                    Log.d(TAG, "Restored temporary disconnect for " + params);
+                }
+            } catch (IllegalArgumentException e) {
+                Log.e(TAG, "Bad format for saved temporary disconnect: " + paramsStr, e);
+                // We won't ever be able to fix a bad parse, so skip it and move on.
+            }
+        }
+    }
+
+    /**
+     * Try once to remove all temporary disconnects.
+     *
+     * If the CarBluetoothUserService is not yet available, or it hasn't yet bound its profile
+     * proxies, the removal will fail, and will need to be retried later.
+     */
+    private void tryRemoveRestoredTemporaryDisconnectsLocked() {
+        HashSet<DisconnectRecord> successfullyRemoved = new HashSet<>();
+
+        for (DisconnectRecord record : mRestoredDisconnects) {
+            if (removeDisconnectRecord(record)) {
+                successfullyRemoved.add(record);
+            }
+        }
+
+        mRestoredDisconnects.removeAll(successfullyRemoved);
+    }
+
+    /**
+     * Keep trying to remove all temporary disconnects that were restored from settings
+     * until all such temporary disconnects have been removed.
+     */
+    private synchronized void removeRestoredTemporaryDisconnects() {
+        tryRemoveRestoredTemporaryDisconnectsLocked();
+
+        if (!mRestoredDisconnects.isEmpty()) {
+            if (DBG) {
+                Log.d(TAG, "Could not remove all restored temporary disconnects - "
+                        + "trying again in " + RESTORE_BACKOFF_MILLIS + "ms");
+            }
+            mHandler.postDelayed(
+                    this::removeRestoredTemporaryDisconnects,
+                    RESTORED_TEMPORARY_DISCONNECT_TOKEN,
+                    RESTORE_BACKOFF_MILLIS);
+        }
+    }
+
+    /** Release all active disconnect records prior to user switch or shutdown. */
+    private synchronized void releaseAllDisconnectRecordsBeforeUnbind() {
+        if (DBG) {
+            Log.d(TAG, "Unbinding CarBluetoothUserService - releasing all temporary disconnects");
+        }
+        for (ConnectionParams params : mTemporaryDisconnects.keySet()) {
+            for (DisconnectRecord record : mTemporaryDisconnects.get(params)) {
+                record.removeSelf();
+            }
+        }
+
+        // Some disconnects might be hanging around because they couldn't be cleaned up.
+        // Make sure they get persisted...
+        saveTemporaryDisconnectsToSettingsLocked();
+        // ...then clear them from the map.
+        mTemporaryDisconnects.clear();
+
+        // We don't need to maintain previously-disconnected profiles any more - they were already
+        // skipped in saveTemporaryDisconnectsToSettingsLocked() above, and they don't need any
+        // further handling when the user resumes.
+        mAlreadyDisabledProfiles.clear();
+
+        // Clean up bookkeeping for restored disconnects. (If any are still around, they'll be
+        // restored again when this user restarts.)
+        mHandler.removeCallbacksAndMessages(RESTORED_TEMPORARY_DISCONNECT_TOKEN);
+        mRestoredDisconnects.clear();
+    }
+
+    /**
      * Add or remove a device based on the bonding state change.
      *
      * @param device    - device to add/remove
@@ -1629,5 +2074,11 @@
         writer.println("*BluetoothDeviceConnectionPolicy*");
         printDeviceMap(writer);
         mBluetoothAutoConnectStateMachine.dump(writer);
+        writer.println("Temporary disconnects active:");
+        String disconnects;
+        synchronized (this) {
+            disconnects = mTemporaryDisconnects.keySet().toString();
+        }
+        writer.println(disconnects);
     }
 }
diff --git a/service/src/com/android/car/CarBluetoothService.java b/service/src/com/android/car/CarBluetoothService.java
index d36db43..a8ffdcc 100644
--- a/service/src/com/android/car/CarBluetoothService.java
+++ b/service/src/com/android/car/CarBluetoothService.java
@@ -15,22 +15,14 @@
  */
 package com.android.car;
 
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_MESSAGING_DEVICE_PRIORITY_0;
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_MESSAGING_DEVICE_PRIORITY_1;
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_MUSIC_DEVICE_PRIORITY_0;
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_MUSIC_DEVICE_PRIORITY_1;
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_NETWORK_DEVICE_PRIORITY_0;
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_NETWORK_DEVICE_PRIORITY_1;
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_PHONE_DEVICE_PRIORITY_0;
-import static android.car.settings.CarSettings.Secure
-        .KEY_BLUETOOTH_AUTOCONNECT_PHONE_DEVICE_PRIORITY_1;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_MESSAGING_DEVICE_PRIORITY_0;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_MESSAGING_DEVICE_PRIORITY_1;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_MUSIC_DEVICE_PRIORITY_0;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_MUSIC_DEVICE_PRIORITY_1;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_NETWORK_DEVICE_PRIORITY_0;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_NETWORK_DEVICE_PRIORITY_1;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_PHONE_DEVICE_PRIORITY_0;
+import static android.car.settings.CarSettings.Secure.KEY_BLUETOOTH_AUTOCONNECT_PHONE_DEVICE_PRIORITY_1;
 
 import android.app.ActivityManager;
 import android.bluetooth.BluetoothDevice;
@@ -39,6 +31,8 @@
 import android.car.ICarBluetooth;
 import android.content.Context;
 import android.content.pm.PackageManager;
+import android.os.Binder;
+import android.os.IBinder;
 import android.provider.Settings;
 import android.util.Log;
 
@@ -57,7 +51,7 @@
     private static final String TAG = "CarBluetoothService";
     private final Context mContext;
     private final BluetoothDeviceConnectionPolicy mBluetoothDeviceConnectionPolicy;
-    private static final boolean DBG = false;
+    private static final boolean DBG = Utils.DBG;
 
     public CarBluetoothService(Context context, CarPropertyService carPropertyService,
             PerUserCarServiceHelper userSwitchService, CarUxRestrictionsManagerService uxrService) {
@@ -158,6 +152,66 @@
     }
 
     /**
+     * Request to disconnect the given profile on the given device, and prevent it from reconnecting
+     * until either the request is released, or the process owning the given token dies.
+     *
+     * @param device The device on which to disconnect a profile.
+     * @param profile The {@link android.bluetooth.BluetoothProfile} to disconnect.
+     * @param token A {@link IBinder} to be used as an identity for the request. If the process
+     *     owning the token dies, the request will automatically be released.
+     * @return True if the profile was successfully disconnected, false if an error occurred.
+     */
+    @Override
+    public boolean requestTemporaryDisconnect(BluetoothDevice device, int profile, IBinder token) {
+        if (DBG) {
+            Log.d(TAG, "requestTemporaryDisconnect device=" + device + " profile=" + profile
+                    + " from uid " + Binder.getCallingUid());
+        }
+        try {
+            enforceBluetoothAdminPermission();
+            if (device == null) {
+                // Will be caught by AIDL and thrown to caller.
+                throw new NullPointerException("Null device in requestTemporaryDisconnect");
+            }
+            return mBluetoothDeviceConnectionPolicy
+                .requestProfileDisconnect(device, profile, token);
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Error in requestTemporaryDisconnect", e);
+            throw e;
+        }
+    }
+
+    /**
+     * Undo a previous call to {@link #requestProfileDisconnect} with the same parameters,
+     * and reconnect the profile if no other requests are active.
+     *
+     * @param device The device on which to release the disconnect request.
+     * @param profile The profile on which to release the disconnect request.
+     * @param token The token provided in the original call to {@link #requestTemporaryDisconnect}.
+     *
+     * @return True if the request was released, false if an error occurred.
+     */
+    @Override
+    public boolean releaseTemporaryDisconnect(BluetoothDevice device, int profile, IBinder token) {
+        if (DBG) {
+            Log.d(TAG, "releaseTemporaryDisconnect device=" + device + " profile=" + profile
+                    + " from uid " + Binder.getCallingUid());
+        }
+        try {
+            enforceBluetoothAdminPermission();
+            if (device == null) {
+                // Will be caught by AIDL and thrown to caller.
+                throw new NullPointerException("Null device in releaseTemporaryDisconnect");
+            }
+            return mBluetoothDeviceConnectionPolicy
+                .releaseProfileDisconnect(device, profile, token);
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Error in releaseTemporaryDisconnect", e);
+            throw e;
+        }
+    }
+
+    /**
      * Returns the Bluetooth device address as a String that has been tagged with the given priority
      * for the given profile.
      *
diff --git a/service/src/com/android/car/CarBluetoothUserService.java b/service/src/com/android/car/CarBluetoothUserService.java
index 1b94742..5a57f19 100644
--- a/service/src/com/android/car/CarBluetoothUserService.java
+++ b/service/src/com/android/car/CarBluetoothUserService.java
@@ -15,22 +15,20 @@
  */
 package com.android.car;
 
-
-import android.bluetooth.BluetoothProfile;
 import android.bluetooth.BluetoothA2dpSink;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothDevice;
 import android.bluetooth.BluetoothHeadsetClient;
 import android.bluetooth.BluetoothMapClient;
-import android.bluetooth.BluetoothPbapClient;
 import android.bluetooth.BluetoothPan;
+import android.bluetooth.BluetoothPbapClient;
+import android.bluetooth.BluetoothProfile;
 import android.car.ICarBluetoothUserService;
 import android.util.Log;
 
 import java.util.Arrays;
 import java.util.List;
 
-
 public class CarBluetoothUserService extends ICarBluetoothUserService.Stub {
     private static final boolean DBG = true;
     private static final String TAG = "CarBluetoothUsrSvc";
@@ -147,8 +145,13 @@
             Log.e(TAG, "Cannot connect to Profile. Proxy Unavailable");
             return;
         }
+        if (device == null) {
+            Log.e(TAG, "Cannot connect to profile on null device");
+            return;
+        }
         if (DBG) {
-            Log.d(TAG, "Trying to connect to " + device.getName() + " Profile: " + profile);
+            Log.d(TAG, "Trying to connect to " + device.getName() + " (" + device.getAddress()
+                    + ") Profile: " + Utils.getProfileName(profile));
         }
         switch (profile) {
             case BluetoothProfile.A2DP_SINK:
@@ -169,12 +172,93 @@
 
             case BluetoothProfile.PAN:
                 mBluetoothPan.connect(device);
+                break;
 
             default:
                 Log.d(TAG, "Unknown profile");
                 break;
         }
-        return;
+    }
+
+    @Override
+    public void bluetoothDisconnectFromProfile(int profile, BluetoothDevice device) {
+        if (!isBluetoothConnectionProxyAvailable(profile)) {
+            Log.e(TAG, "Cannot disconnect from profile. Proxy Unavailable");
+            return;
+        }
+        if (device == null) {
+            Log.e(TAG, "Cannot disconnect from profile on null device");
+            return;
+        }
+        if (DBG) {
+            Log.d(TAG, "Trying to disconnect from " + device.getName() + " (" + device.getAddress()
+                    + ") Profile: " + Utils.getProfileName(profile));
+        }
+        switch (profile) {
+            case BluetoothProfile.A2DP_SINK:
+                mBluetoothA2dpSink.disconnect(device);
+                break;
+
+            case BluetoothProfile.HEADSET_CLIENT:
+                mBluetoothHeadsetClient.disconnect(device);
+                break;
+
+            case BluetoothProfile.MAP_CLIENT:
+                mBluetoothMapClient.disconnect(device);
+                break;
+
+            case BluetoothProfile.PBAP_CLIENT:
+                mBluetoothPbapClient.disconnect(device);
+                break;
+
+            case BluetoothProfile.PAN:
+                mBluetoothPan.disconnect(device);
+                break;
+
+            default:
+                Log.d(TAG, "Unknown profile");
+                break;
+        }
+    }
+
+    /**
+     * Get the priority of the given Bluetooth profile for the given remote device
+     * @param profile - Bluetooth profile
+     * @param device - remote Bluetooth device
+     */
+    @Override
+    public int getProfilePriority(int profile, BluetoothDevice device) {
+        if (!isBluetoothConnectionProxyAvailable(profile)) {
+            Log.e(TAG, "Cannot get profile priority. Proxy Unavailable");
+            return BluetoothProfile.PRIORITY_UNDEFINED;
+        }
+        if (device == null) {
+            Log.e(TAG, "Cannot get profile priority on null device");
+            return BluetoothProfile.PRIORITY_UNDEFINED;
+        }
+        int priority;
+        switch (profile) {
+            case BluetoothProfile.A2DP_SINK:
+                priority = mBluetoothA2dpSink.getPriority(device);
+                break;
+            case BluetoothProfile.HEADSET_CLIENT:
+                priority = mBluetoothHeadsetClient.getPriority(device);
+                break;
+            case BluetoothProfile.MAP_CLIENT:
+                priority = mBluetoothMapClient.getPriority(device);
+                break;
+            case BluetoothProfile.PBAP_CLIENT:
+                priority = mBluetoothPbapClient.getPriority(device);
+                break;
+            default:
+                Log.d(TAG, "Unknown Profile");
+                return BluetoothProfile.PRIORITY_UNDEFINED;
+        }
+        if (DBG) {
+            Log.d(TAG, Utils.getProfileName(profile) + " priority for " + device.getName() + " ("
+                    + device.getAddress() + ") = " + priority);
+        }
+        return priority;
     }
 
     /**
@@ -186,9 +270,17 @@
     @Override
     public void setProfilePriority(int profile, BluetoothDevice device, int priority) {
         if (!isBluetoothConnectionProxyAvailable(profile)) {
-            Log.e(TAG, "Cannot connect to Profile. Proxy Unavailable");
+            Log.e(TAG, "Cannot set profile priority. Proxy Unavailable");
             return;
         }
+        if (device == null) {
+            Log.e(TAG, "Cannot set profile priority on null device");
+            return;
+        }
+        if (DBG) {
+            Log.d(TAG, "Setting " + Utils.getProfileName(profile) + " priority for "
+                    + device.getName() + " (" + device.getAddress() + ") to " + priority);
+        }
         switch (profile) {
             case BluetoothProfile.A2DP_SINK:
                 mBluetoothA2dpSink.setPriority(device, priority);
diff --git a/service/src/com/android/car/SetMultimap.java b/service/src/com/android/car/SetMultimap.java
new file mode 100644
index 0000000..48e8739
--- /dev/null
+++ b/service/src/com/android/car/SetMultimap.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A simple implementation of a multimap that maps keys to sets of values.
+ *
+ * This class is (and should remain) drop-in replaceable with Guava's SetMultimap.
+ *
+ * @param <K> The type of the keys in the map.
+ * @param <V> The type of the values in the map.
+ */
+public class SetMultimap<K, V> {
+    private Map<K, Set<V>> mMap;
+
+    /** Creates a new {@link #SetMultimap}. */
+    public SetMultimap() {
+        mMap = new HashMap<>();
+    }
+
+    /** Gets the set of values associated with a given key. */
+    public Set<V> get(K key) {
+        return Collections.unmodifiableSet(mMap.getOrDefault(key, Collections.emptySet()));
+    }
+
+    /** Adds a value to the set associated with a key. */
+    public boolean put(K key, V value) {
+        return mMap.computeIfAbsent(key, k -> new HashSet<>()).add(value);
+    }
+
+    /** Checks if the multimap contains the given key and value. */
+    public boolean containsEntry(K key, V value) {
+        Set<V> set = mMap.get(key);
+        return set != null && set.contains(value);
+    }
+
+    /** Removes the given value from the set of the given key. */
+    public boolean remove(K key, V value) {
+        Set<V> set = mMap.get(key);
+        if (set == null) {
+            return false;
+        }
+
+        boolean removed = set.remove(value);
+        if (set.isEmpty()) {
+            mMap.remove(key);
+        }
+        return removed;
+    }
+
+    /** Clears all entries in the map. */
+    public void clear() {
+        mMap.clear();
+    }
+
+    /** Gets the set of keys stored in the map. */
+    public Set<K> keySet() {
+        return Collections.unmodifiableSet(mMap.keySet());
+    }
+}