Merge "Add picker page for Bluetooth devices." into pi-car-dev
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 6c10951..2ef39a1 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -248,6 +248,16 @@
             <meta-data android:name="distractionOptimized" android:value="true"/>
         </activity>
 
+        <activity android:name=".bluetooth.BluetoothDevicePickerActivity"
+                  android:label="@string/bluetooth_device_picker"
+                  android:configChanges="orientation|keyboardHidden|screenSize"
+                  android:clearTaskOnLaunch="true">
+            <intent-filter>
+                <action android:name="android.bluetooth.devicepicker.action.LAUNCH" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
         <activity android:name=".accounts.AddAccountActivity"
                   android:theme="@android:style/Theme.Translucent.NoTitleBar"
                   android:configChanges="orientation|keyboardHidden|screenSize"/>
diff --git a/res/values/preference_keys.xml b/res/values/preference_keys.xml
index fd6b2a5..cf33c99 100644
--- a/res/values/preference_keys.xml
+++ b/res/values/preference_keys.xml
@@ -108,6 +108,7 @@
     </string>
     <string name="pk_bluetooth_device_address" translatable="false">bluetooth_device_address
     </string>
+    <string name="pk_bluetooth_device_picker" translatable="false">bluetooth_device_picker</string>
 
     <!-- Applications and Notifications Settings -->
     <string name="pk_applications_settings_screen_entry" translatable="false">
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 3b36795..3b38e1e 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -315,6 +315,8 @@
     <string name="bluetooth_notif_title">Pairing request</string>
     <!-- Notification message when a Bluetooth device wants to pair with us -->
     <string name="bluetooth_notif_message">Tap to pair with <xliff:g id="device_name">%1$s</xliff:g>.</string>
+    <!-- Title for page which supports selecting a Bluetooth device from other applications. [CHAR_LIMIT=40]-->
+    <string name="bluetooth_device_picker">Choose Bluetooth device</string>
 
     <!-- Language settings screen heading. [CHAR LIMIT=30] -->
     <string name="language_settings">Languages</string>
diff --git a/res/xml/bluetooth_device_picker_fragment.xml b/res/xml/bluetooth_device_picker_fragment.xml
new file mode 100644
index 0000000..95dcacd
--- /dev/null
+++ b/res/xml/bluetooth_device_picker_fragment.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright 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.
+-->
+
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:settings="http://schemas.android.com/apk/res-auto"
+    android:title="@string/bluetooth_device_picker">
+    <com.android.car.settings.common.LogicalPreferenceGroup
+        android:key="@string/pk_bluetooth_device_picker"
+        settings:controller="com.android.car.settings.bluetooth.BluetoothDevicePickerPreferenceController"/>
+</PreferenceScreen>
diff --git a/src/com/android/car/settings/bluetooth/BluetoothDevicePickerActivity.java b/src/com/android/car/settings/bluetooth/BluetoothDevicePickerActivity.java
new file mode 100644
index 0000000..db89075
--- /dev/null
+++ b/src/com/android/car/settings/bluetooth/BluetoothDevicePickerActivity.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 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.settings.bluetooth;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import com.android.car.settings.common.BaseCarSettingsActivity;
+
+/**
+ * Displays a list of Bluetooth devices at the request of another application. When a user selects a
+ * device from the list, its details are returned to the requester. See {@link
+ * android.bluetooth.BluetoothDevicePicker}.
+ */
+public class BluetoothDevicePickerActivity extends BaseCarSettingsActivity {
+
+    @Nullable
+    @Override
+    protected Fragment getInitialFragment() {
+        return new BluetoothDevicePickerFragment();
+    }
+}
diff --git a/src/com/android/car/settings/bluetooth/BluetoothDevicePickerFragment.java b/src/com/android/car/settings/bluetooth/BluetoothDevicePickerFragment.java
new file mode 100644
index 0000000..658690d
--- /dev/null
+++ b/src/com/android/car/settings/bluetooth/BluetoothDevicePickerFragment.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 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.settings.bluetooth;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.ProgressBar;
+
+import androidx.annotation.XmlRes;
+
+import com.android.car.settings.R;
+import com.android.car.settings.common.SettingsFragment;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+/**
+ * Hosts {@link BluetoothDevicePickerPreferenceController} to display the list of Bluetooth
+ * devices. The progress bar is shown while this fragment is visible to indicate discovery or
+ * pairing progress.
+ */
+public class BluetoothDevicePickerFragment extends SettingsFragment {
+
+    private LocalBluetoothManager mManager;
+    private ProgressBar mProgressBar;
+
+    @Override
+    @XmlRes
+    protected int getPreferenceScreenResId() {
+        return R.xml.bluetooth_device_picker_fragment;
+    }
+
+    @Override
+    public void onAttach(Context context) {
+        super.onAttach(context);
+        mManager = BluetoothUtils.getLocalBtManager(context);
+        if (mManager == null) {
+            goBack();
+            return;
+        }
+
+        use(BluetoothDevicePickerPreferenceController.class,
+                R.string.pk_bluetooth_device_picker).setLaunchIntent(requireActivity().getIntent());
+    }
+
+    @Override
+    public void onActivityCreated(Bundle savedInstanceState) {
+        super.onActivityCreated(savedInstanceState);
+        mProgressBar = requireActivity().findViewById(R.id.progress_bar);
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        mManager.setForegroundActivity(requireActivity());
+        mProgressBar.setVisibility(View.VISIBLE);
+    }
+
+    @Override
+    public void onStop() {
+        super.onStop();
+        mManager.setForegroundActivity(null);
+        mProgressBar.setVisibility(View.GONE);
+    }
+}
diff --git a/src/com/android/car/settings/bluetooth/BluetoothDevicePickerPreferenceController.java b/src/com/android/car/settings/bluetooth/BluetoothDevicePickerPreferenceController.java
new file mode 100644
index 0000000..0c68eb5
--- /dev/null
+++ b/src/com/android/car/settings/bluetooth/BluetoothDevicePickerPreferenceController.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 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.settings.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothDevicePicker;
+import android.car.drivingstate.CarUxRestrictions;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.car.settings.R;
+import com.android.car.settings.common.FragmentController;
+import com.android.car.settings.common.Logger;
+import com.android.settingslib.bluetooth.BluetoothDeviceFilter;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+/**
+ * Displays a list of Bluetooth devices for the user to select. When a device is selected, a
+ * {@link BluetoothDevicePicker#ACTION_DEVICE_SELECTED} broadcast is sent containing {@link
+ * BluetoothDevice#EXTRA_DEVICE}.
+ *
+ * <p>This is useful to other application to obtain a device without needing to implement the UI.
+ * The activity hosting this controller should be launched with an intent as detailed in {@link
+ * BluetoothDevicePicker#ACTION_LAUNCH}. This controller will filter devices as specified by {@link
+ * BluetoothDevicePicker#EXTRA_FILTER_TYPE} and deliver the broadcast to the specified {@link
+ * BluetoothDevicePicker#EXTRA_LAUNCH_PACKAGE} {@link BluetoothDevicePicker#EXTRA_LAUNCH_CLASS}
+ * component.  If authentication is required ({@link BluetoothDevicePicker#EXTRA_NEED_AUTH}), this
+ * controller will initiate pairing with the device and send the selected broadcast once the device
+ * successfully pairs. If no device is selected and this controller is destroyed, a broadcast with
+ * a {@code null} {@link BluetoothDevice#EXTRA_DEVICE} is sent.
+ */
+public class BluetoothDevicePickerPreferenceController extends
+        BluetoothScanningDevicesGroupPreferenceController {
+
+    private static final Logger LOG = new Logger(BluetoothDevicePickerPreferenceController.class);
+
+    private BluetoothDeviceFilter.Filter mFilter;
+
+    private boolean mNeedAuth;
+    private String mLaunchPackage;
+    private String mLaunchClass;
+
+    private CachedBluetoothDevice mSelectedDevice;
+
+    public BluetoothDevicePickerPreferenceController(Context context, String preferenceKey,
+            FragmentController fragmentController, CarUxRestrictions uxRestrictions) {
+        super(context, preferenceKey, fragmentController, uxRestrictions);
+    }
+
+    /**
+     * Sets the intent with which {@link BluetoothDevicePickerActivity} was launched. The intent
+     * may contain {@link BluetoothDevicePicker} extras to customize the selection list and specify
+     * the destination of the selected device. See {@link BluetoothDevicePicker#ACTION_LAUNCH}.
+     */
+    public void setLaunchIntent(Intent intent) {
+        mNeedAuth = intent.getBooleanExtra(BluetoothDevicePicker.EXTRA_NEED_AUTH, false);
+        mFilter = BluetoothDeviceFilter.getFilter(
+                intent.getIntExtra(BluetoothDevicePicker.EXTRA_FILTER_TYPE,
+                        BluetoothDevicePicker.FILTER_TYPE_ALL));
+        mLaunchPackage = intent.getStringExtra(BluetoothDevicePicker.EXTRA_LAUNCH_PACKAGE);
+        mLaunchClass = intent.getStringExtra(BluetoothDevicePicker.EXTRA_LAUNCH_CLASS);
+    }
+
+    @Override
+    protected void checkInitialized() {
+        if (mFilter == null) {
+            throw new IllegalStateException("launch intent must be set");
+        }
+    }
+
+    @Override
+    protected BluetoothDeviceFilter.Filter getDeviceFilter() {
+        return mFilter;
+    }
+
+    @Override
+    protected void onDeviceClickedInternal(CachedBluetoothDevice cachedDevice) {
+        mSelectedDevice = cachedDevice;
+        BluetoothUtils.persistSelectedDeviceInPicker(getContext(), cachedDevice.getAddress());
+
+        if (cachedDevice.getBondState() == BluetoothDevice.BOND_BONDED || !mNeedAuth) {
+            sendDevicePickedIntent(cachedDevice.getDevice());
+            getFragmentController().goBack();
+            return;
+        }
+
+        if (cachedDevice.startPairing()) {
+            LOG.d("startPairing");
+        } else {
+            BluetoothUtils.showError(getContext(), cachedDevice.getName(),
+                    R.string.bluetooth_pairing_error_message);
+            refreshUi();
+        }
+    }
+
+    @Override
+    protected void onStartInternal() {
+        super.onStartInternal();
+        mSelectedDevice = null;
+    }
+
+    @Override
+    protected void onDestroyInternal() {
+        super.onDestroyInternal();
+        if (mSelectedDevice == null) {
+            // Notify that no device was selected.
+            sendDevicePickedIntent(null);
+        }
+    }
+
+    @Override
+    public void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState) {
+        super.onDeviceBondStateChanged(cachedDevice, bondState);
+        if (bondState == BluetoothDevice.BOND_BONDED && cachedDevice.equals(mSelectedDevice)) {
+            sendDevicePickedIntent(mSelectedDevice.getDevice());
+            getFragmentController().goBack();
+        }
+    }
+
+    private void sendDevicePickedIntent(BluetoothDevice device) {
+        LOG.d("sendDevicePickedIntent device: " + device + " package: " + mLaunchPackage
+                + " class: " + mLaunchClass);
+        Intent intent = new Intent(BluetoothDevicePicker.ACTION_DEVICE_SELECTED);
+        intent.putExtra(BluetoothDevice.EXTRA_DEVICE, device);
+        if (mLaunchPackage != null && mLaunchClass != null) {
+            intent.setClassName(mLaunchPackage, mLaunchClass);
+        }
+        getContext().sendBroadcast(intent);
+    }
+}
diff --git a/src/com/android/car/settings/bluetooth/BluetoothUtils.java b/src/com/android/car/settings/bluetooth/BluetoothUtils.java
index 6bda46b..731ee0b 100644
--- a/src/com/android/car/settings/bluetooth/BluetoothUtils.java
+++ b/src/com/android/car/settings/bluetooth/BluetoothUtils.java
@@ -161,6 +161,13 @@
         return false;
     }
 
+    static void persistSelectedDeviceInPicker(Context context, String deviceAddress) {
+        SharedPreferences.Editor editor = getSharedPreferences(context).edit();
+        editor.putString(KEY_LAST_SELECTED_DEVICE, deviceAddress);
+        editor.putLong(KEY_LAST_SELECTED_DEVICE_TIME, System.currentTimeMillis());
+        editor.apply();
+    }
+
     public static LocalBluetoothManager getLocalBtManager(Context context) {
         return LocalBluetoothManager.getInstance(context, mOnInitCallback);
     }
diff --git a/tests/robotests/src/com/android/car/settings/bluetooth/BluetoothDevicePickerFragmentTest.java b/tests/robotests/src/com/android/car/settings/bluetooth/BluetoothDevicePickerFragmentTest.java
new file mode 100644
index 0000000..d6c877b
--- /dev/null
+++ b/tests/robotests/src/com/android/car/settings/bluetooth/BluetoothDevicePickerFragmentTest.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 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.settings.bluetooth;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.View;
+import android.widget.ProgressBar;
+
+import com.android.car.settings.CarSettingsRobolectricTestRunner;
+import com.android.car.settings.R;
+import com.android.car.settings.testutils.FragmentController;
+import com.android.car.settings.testutils.ShadowBluetoothAdapter;
+import com.android.car.settings.testutils.ShadowBluetoothPan;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/** Unit test for {@link BluetoothDevicePickerFragment}. */
+@RunWith(CarSettingsRobolectricTestRunner.class)
+@Config(shadows = {ShadowBluetoothAdapter.class, ShadowBluetoothPan.class})
+public class BluetoothDevicePickerFragmentTest {
+
+    private LocalBluetoothManager mLocalBluetoothManager;
+    private FragmentController<BluetoothDevicePickerFragment> mFragmentController;
+    private BluetoothDevicePickerFragment mFragment;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        Context context = RuntimeEnvironment.application;
+        mLocalBluetoothManager = LocalBluetoothManager.getInstance(context, /* onInitCallback= */
+                null);
+
+        mFragment = new BluetoothDevicePickerFragment();
+        mFragmentController = FragmentController.of(mFragment);
+    }
+
+    @After
+    public void tearDown() {
+        ShadowBluetoothAdapter.reset();
+    }
+
+    @Test
+    public void onStart_setsBluetoothManagerForegroundActivity() {
+        mFragmentController.create().start();
+
+        assertThat(mLocalBluetoothManager.getForegroundActivity()).isEqualTo(
+                mFragment.requireActivity());
+    }
+
+    @Test
+    public void onStart_showsProgressBar() {
+        mFragmentController.create();
+        ProgressBar progressBar = findProgressBar(mFragment.requireActivity());
+        progressBar.setVisibility(View.GONE);
+
+        mFragmentController.start();
+
+        assertThat(progressBar.getVisibility()).isEqualTo(View.VISIBLE);
+    }
+
+    @Test
+    public void onStop_clearsBluetoothManagerForegroundActivity() {
+        mFragmentController.create().start().resume().pause().stop();
+
+        assertThat(mLocalBluetoothManager.getForegroundActivity()).isNull();
+    }
+
+    @Test
+    public void onStop_hidesProgressBar() {
+        mFragmentController.setup().onPause();
+        ProgressBar progressBar = findProgressBar(mFragment.requireActivity());
+        progressBar.setVisibility(View.VISIBLE);
+
+        mFragmentController.stop();
+
+        assertThat(progressBar.getVisibility()).isEqualTo(View.GONE);
+    }
+
+    private ProgressBar findProgressBar(Activity activity) {
+        return activity.findViewById(R.id.progress_bar);
+    }
+}
diff --git a/tests/robotests/src/com/android/car/settings/bluetooth/BluetoothDevicePickerPreferenceControllerTest.java b/tests/robotests/src/com/android/car/settings/bluetooth/BluetoothDevicePickerPreferenceControllerTest.java
new file mode 100644
index 0000000..1c4934a4
--- /dev/null
+++ b/tests/robotests/src/com/android/car/settings/bluetooth/BluetoothDevicePickerPreferenceControllerTest.java
@@ -0,0 +1,311 @@
+/*
+ * Copyright 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.settings.bluetooth;
+
+import static android.content.pm.PackageManager.FEATURE_BLUETOOTH;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertThrows;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothDevicePicker;
+import android.bluetooth.BluetoothUuid;
+import android.car.userlib.CarUserManagerHelper;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.ParcelUuid;
+
+import androidx.lifecycle.Lifecycle;
+import androidx.preference.PreferenceGroup;
+
+import com.android.car.settings.CarSettingsRobolectricTestRunner;
+import com.android.car.settings.common.LogicalPreferenceGroup;
+import com.android.car.settings.common.PreferenceControllerTestHelper;
+import com.android.car.settings.testutils.ShadowBluetoothAdapter;
+import com.android.car.settings.testutils.ShadowBluetoothPan;
+import com.android.car.settings.testutils.ShadowCarUserManagerHelper;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.util.ReflectionHelpers;
+
+import java.util.Arrays;
+
+/** Unit test for {@link BluetoothDevicePickerPreferenceController}. */
+@RunWith(CarSettingsRobolectricTestRunner.class)
+@Config(shadows = {ShadowCarUserManagerHelper.class, ShadowBluetoothAdapter.class,
+        ShadowBluetoothPan.class})
+public class BluetoothDevicePickerPreferenceControllerTest {
+
+    @Mock
+    private CarUserManagerHelper mCarUserManagerHelper;
+    @Mock
+    private CachedBluetoothDevice mUnbondedCachedDevice;
+    @Mock
+    private BluetoothDevice mUnbondedDevice;
+    @Mock
+    private CachedBluetoothDevice mBondedCachedDevice;
+    @Mock
+    private BluetoothDevice mBondedDevice;
+    @Mock
+    private CachedBluetoothDeviceManager mCachedDeviceManager;
+    private CachedBluetoothDeviceManager mSaveRealCachedDeviceManager;
+    private LocalBluetoothManager mLocalBluetoothManager;
+    private PreferenceGroup mPreferenceGroup;
+    private PreferenceControllerTestHelper<BluetoothDevicePickerPreferenceController>
+            mControllerHelper;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        ShadowCarUserManagerHelper.setMockInstance(mCarUserManagerHelper);
+        Context context = RuntimeEnvironment.application;
+
+        mLocalBluetoothManager = LocalBluetoothManager.getInstance(context, /* onInitCallback= */
+                null);
+        mSaveRealCachedDeviceManager = mLocalBluetoothManager.getCachedDeviceManager();
+        ReflectionHelpers.setField(mLocalBluetoothManager, "mCachedDeviceManager",
+                mCachedDeviceManager);
+
+        when(mUnbondedDevice.getBondState()).thenReturn(BluetoothDevice.BOND_NONE);
+        when(mUnbondedCachedDevice.getBondState()).thenReturn(BluetoothDevice.BOND_NONE);
+        when(mUnbondedCachedDevice.getDevice()).thenReturn(mUnbondedDevice);
+        when(mBondedDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
+        when(mBondedCachedDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
+        when(mBondedCachedDevice.getDevice()).thenReturn(mBondedDevice);
+        when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(
+                Arrays.asList(mUnbondedCachedDevice, mBondedCachedDevice));
+        // Make bonded device appear first in the list.
+        when(mBondedCachedDevice.compareTo(mUnbondedCachedDevice)).thenReturn(-1);
+        when(mUnbondedCachedDevice.compareTo(mBondedCachedDevice)).thenReturn(1);
+
+        // Make sure controller is available.
+        Shadows.shadowOf(context.getPackageManager()).setSystemFeature(
+                FEATURE_BLUETOOTH, /* supported= */ true);
+        BluetoothAdapter.getDefaultAdapter().enable();
+        getShadowBluetoothAdapter().setState(BluetoothAdapter.STATE_ON);
+
+        mPreferenceGroup = new LogicalPreferenceGroup(context);
+        mControllerHelper = new PreferenceControllerTestHelper<>(context,
+                BluetoothDevicePickerPreferenceController.class);
+    }
+
+    @After
+    public void tearDown() {
+        ShadowCarUserManagerHelper.reset();
+        ShadowBluetoothAdapter.reset();
+        ReflectionHelpers.setField(mLocalBluetoothManager, "mCachedDeviceManager",
+                mSaveRealCachedDeviceManager);
+    }
+
+    @Test
+    public void checkInitialized_noLaunchIntentSet_throwsIllegalStateException() {
+        assertThrows(IllegalStateException.class,
+                () -> mControllerHelper.setPreference(mPreferenceGroup));
+    }
+
+    @Test
+    public void onStart_appliesFilterType() {
+        // Setup device to pass the filter.
+        when(mBondedDevice.getUuids()).thenReturn(new ParcelUuid[]{BluetoothUuid.AudioSink});
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ false,
+                BluetoothDevicePicker.FILTER_TYPE_AUDIO, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_START);
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+        assertThat(((BluetoothDevicePreference) mPreferenceGroup.getPreference(
+                0)).getCachedDevice()).isEqualTo(mBondedCachedDevice);
+    }
+
+    @Test
+    public void onDeviceClicked_bondedDevice_sendsPickedIntent() {
+        ComponentName component = new ComponentName("test.package", "TestClass");
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ true,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, component.getPackageName(),
+                component.getClassName());
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        BluetoothDevicePreference devicePreference =
+                (BluetoothDevicePreference) mPreferenceGroup.getPreference(0);
+
+        devicePreference.performClick();
+
+        assertThat(ShadowApplication.getInstance().getBroadcastIntents()).hasSize(1);
+        Intent pickedIntent = ShadowApplication.getInstance().getBroadcastIntents().get(0);
+        assertThat(pickedIntent.getAction()).isEqualTo(
+                BluetoothDevicePicker.ACTION_DEVICE_SELECTED);
+        assertThat(pickedIntent.getComponent()).isEqualTo(component);
+        assertThat((BluetoothDevice) pickedIntent.getParcelableExtra(
+                BluetoothDevice.EXTRA_DEVICE)).isEqualTo(mBondedDevice);
+    }
+
+    @Test
+    public void onDeviceClicked_bondedDevice_goesBack() {
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ true,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        BluetoothDevicePreference devicePreference =
+                (BluetoothDevicePreference) mPreferenceGroup.getPreference(0);
+
+        devicePreference.performClick();
+
+        verify(mControllerHelper.getMockFragmentController()).goBack();
+    }
+
+    @Test
+    public void onDeviceClicked_unbondedDevice_doesNotNeedAuth_sendsPickedIntent() {
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ false,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        BluetoothDevicePreference devicePreference =
+                (BluetoothDevicePreference) mPreferenceGroup.getPreference(1);
+
+        devicePreference.performClick();
+
+        Intent pickedIntent = ShadowApplication.getInstance().getBroadcastIntents().get(0);
+        assertThat(pickedIntent.getAction()).isEqualTo(
+                BluetoothDevicePicker.ACTION_DEVICE_SELECTED);
+    }
+
+    @Test
+    public void onDeviceClicked_unbondedDevice_needsAuth_startsPairing() {
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ true,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        BluetoothDevicePreference devicePreference =
+                (BluetoothDevicePreference) mPreferenceGroup.getPreference(1);
+
+        devicePreference.performClick();
+
+        verify(mUnbondedCachedDevice).startPairing();
+    }
+
+    @Test
+    public void onDeviceClicked_unbondedDevice_needsAuth_pairingStartFails_resumesScanning() {
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ true,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        BluetoothDevicePreference devicePreference =
+                (BluetoothDevicePreference) mPreferenceGroup.getPreference(1);
+        when(mUnbondedCachedDevice.startPairing()).thenReturn(false);
+        assertThat(BluetoothAdapter.getDefaultAdapter().isDiscovering()).isTrue();
+
+        devicePreference.performClick();
+
+        assertThat(BluetoothAdapter.getDefaultAdapter().isDiscovering()).isTrue();
+    }
+
+    @Test
+    public void onDeviceBondStateChanged_selectedDeviceBonded_sendsPickedIntent() {
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ true,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        BluetoothDevicePreference devicePreference =
+                (BluetoothDevicePreference) mPreferenceGroup.getPreference(1);
+
+        // Select device.
+        devicePreference.performClick();
+        // Device bonds.
+        mControllerHelper.getController().onDeviceBondStateChanged(
+                devicePreference.getCachedDevice(), BluetoothDevice.BOND_BONDED);
+
+        Intent pickedIntent = ShadowApplication.getInstance().getBroadcastIntents().get(0);
+        assertThat(pickedIntent.getAction()).isEqualTo(
+                BluetoothDevicePicker.ACTION_DEVICE_SELECTED);
+    }
+
+    @Test
+    public void onDeviceBondStateChanged_selectedDeviceBonded_goesBack() {
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ true,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        BluetoothDevicePreference devicePreference =
+                (BluetoothDevicePreference) mPreferenceGroup.getPreference(1);
+
+        // Select device.
+        devicePreference.performClick();
+        // Device bonds.
+        mControllerHelper.getController().onDeviceBondStateChanged(
+                devicePreference.getCachedDevice(), BluetoothDevice.BOND_BONDED);
+
+        verify(mControllerHelper.getMockFragmentController()).goBack();
+    }
+
+    @Test
+    public void onDestroy_noDeviceSelected_sendsNullPickedIntent() {
+        Intent launchIntent = createLaunchIntent(/* needsAuth= */ true,
+                BluetoothDevicePicker.FILTER_TYPE_ALL, "test.package", "TestClass");
+        mControllerHelper.getController().setLaunchIntent(launchIntent);
+        mControllerHelper.setPreference(mPreferenceGroup);
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_DESTROY);
+
+        Intent pickedIntent = ShadowApplication.getInstance().getBroadcastIntents().get(0);
+        assertThat(pickedIntent.getAction()).isEqualTo(
+                BluetoothDevicePicker.ACTION_DEVICE_SELECTED);
+        assertThat((BluetoothDevice) pickedIntent.getParcelableExtra(
+                BluetoothDevice.EXTRA_DEVICE)).isNull();
+    }
+
+    private Intent createLaunchIntent(boolean needAuth, int filterType, String packageName,
+            String className) {
+        Intent intent = new Intent(BluetoothDevicePicker.ACTION_LAUNCH);
+        intent.putExtra(BluetoothDevicePicker.EXTRA_NEED_AUTH, needAuth);
+        intent.putExtra(BluetoothDevicePicker.EXTRA_FILTER_TYPE, filterType);
+        intent.putExtra(BluetoothDevicePicker.EXTRA_LAUNCH_PACKAGE, packageName);
+        intent.putExtra(BluetoothDevicePicker.EXTRA_LAUNCH_CLASS, className);
+        return intent;
+    }
+
+    private ShadowBluetoothAdapter getShadowBluetoothAdapter() {
+        return (ShadowBluetoothAdapter) Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+    }
+}