1 /* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.car.settings.bluetooth; 17 18 import android.bluetooth.BluetoothAdapter; 19 import android.bluetooth.BluetoothClass; 20 import android.bluetooth.BluetoothDevice; 21 import android.content.Context; 22 import android.content.res.Resources; 23 import android.os.AsyncTask; 24 import android.os.Handler; 25 import android.os.Looper; 26 import android.os.SystemProperties; 27 import android.support.v7.widget.RecyclerView; 28 import android.util.Pair; 29 import android.view.LayoutInflater; 30 import android.view.View; 31 import android.view.View.OnClickListener; 32 import android.view.ViewGroup; 33 import android.widget.ImageButton; 34 import android.widget.ImageView; 35 import android.widget.TextView; 36 import android.widget.Toast; 37 38 import androidx.car.widget.PagedListView; 39 40 import com.android.car.settings.R; 41 import com.android.car.settings.common.BaseFragment; 42 import com.android.car.settings.common.Logger; 43 import com.android.settingslib.bluetooth.BluetoothCallback; 44 import com.android.settingslib.bluetooth.BluetoothDeviceFilter; 45 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 46 import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; 47 import com.android.settingslib.bluetooth.HidProfile; 48 import com.android.settingslib.bluetooth.LocalBluetoothAdapter; 49 import com.android.settingslib.bluetooth.LocalBluetoothManager; 50 import com.android.settingslib.bluetooth.LocalBluetoothProfile; 51 52 import java.util.ArrayList; 53 import java.util.Collection; 54 import java.util.Collections; 55 import java.util.HashSet; 56 import java.util.List; 57 import java.util.Set; 58 59 /** 60 * Renders {@link android.bluetooth.BluetoothDevice} to a view to be displayed as a row in a list. 61 */ 62 public class BluetoothDeviceListAdapter 63 extends RecyclerView.Adapter<BluetoothDeviceListAdapter.ViewHolder> 64 implements PagedListView.ItemCap, BluetoothCallback { 65 private static final Logger LOG = new Logger(BluetoothDeviceListAdapter.class); 66 // Copied from BluetoothDeviceNoNamePreferenceController.java 67 private static final String BLUETOOTH_SHOW_DEVICES_WITHOUT_NAMES_PROPERTY = 68 "persist.bluetooth.showdeviceswithoutnames"; 69 private static final int DEVICE_ROW_TYPE = 1; 70 private static final int BONDED_DEVICE_HEADER_TYPE = 2; 71 private static final int AVAILABLE_DEVICE_HEADER_TYPE = 3; 72 private static final int NUM_OF_HEADERS = 2; 73 public static final int DELAY_MILLIS = 1000; 74 75 private final Handler mHandler = new Handler(Looper.getMainLooper()); 76 private final HashSet<CachedBluetoothDevice> mBondedDevices = new HashSet<>(); 77 private final HashSet<CachedBluetoothDevice> mAvailableDevices = new HashSet<>(); 78 private final LocalBluetoothAdapter mLocalAdapter; 79 private final LocalBluetoothManager mLocalManager; 80 private final CachedBluetoothDeviceManager mDeviceManager; 81 private final Context mContext; 82 private final BaseFragment.FragmentController mFragmentController; 83 private final boolean mShowDevicesWithoutNames; 84 85 /* Talk-back descriptions for various BT icons */ 86 public final String mComputerDescription; 87 public final String mInputPeripheralDescription; 88 public final String mHeadsetDescription; 89 public final String mPhoneDescription; 90 public final String mImagingDescription; 91 public final String mHeadphoneDescription; 92 public final String mBluetoothDescription; 93 94 private SortTask mSortTask; 95 96 private ArrayList<CachedBluetoothDevice> mBondedDevicesSorted = new ArrayList<>(); 97 private ArrayList<CachedBluetoothDevice> mAvailableDevicesSorted = new ArrayList<>(); 98 99 class ViewHolder extends RecyclerView.ViewHolder { 100 private final ImageView mIcon; 101 private final TextView mTitle; 102 private final TextView mDesc; 103 private final ImageButton mActionButton; 104 private final DeviceAttributeChangeCallback mCallback = 105 new DeviceAttributeChangeCallback(this); 106 107 public ViewHolder(View view) { 108 super(view); 109 mTitle = (TextView) view.findViewById(R.id.title); 110 mDesc = (TextView) view.findViewById(R.id.desc); 111 mIcon = (ImageView) view.findViewById(R.id.icon); 112 mActionButton = (ImageButton) view.findViewById(R.id.action); 113 view.setOnClickListener(new BluetoothClickListener(this)); 114 } 115 } 116 117 public BluetoothDeviceListAdapter( 118 Context context, 119 LocalBluetoothManager localBluetoothManager, 120 BaseFragment.FragmentController fragmentController) { 121 mContext = context; 122 mLocalManager = localBluetoothManager; 123 mFragmentController = fragmentController; 124 mLocalAdapter = mLocalManager.getBluetoothAdapter(); 125 mDeviceManager = mLocalManager.getCachedDeviceManager(); 126 127 Resources r = context.getResources(); 128 mComputerDescription = r.getString(R.string.bluetooth_talkback_computer); 129 mInputPeripheralDescription = r.getString( 130 R.string.bluetooth_talkback_input_peripheral); 131 mHeadsetDescription = r.getString(R.string.bluetooth_talkback_headset); 132 mPhoneDescription = r.getString(R.string.bluetooth_talkback_phone); 133 mImagingDescription = r.getString(R.string.bluetooth_talkback_imaging); 134 mHeadphoneDescription = r.getString(R.string.bluetooth_talkback_headphone); 135 mBluetoothDescription = r.getString(R.string.bluetooth_talkback_bluetooth); 136 mShowDevicesWithoutNames = 137 SystemProperties.getBoolean(BLUETOOTH_SHOW_DEVICES_WITHOUT_NAMES_PROPERTY, false); 138 } 139 140 public void start() { 141 mLocalManager.getEventManager().registerCallback(this); 142 if (mLocalAdapter.isEnabled()) { 143 mLocalAdapter.startScanning(true); 144 addBondDevices(); 145 addCachedDevices(); 146 } 147 // create task here to avoid re-executing existing tasks. 148 mSortTask = new SortTask(); 149 mSortTask.execute(); 150 } 151 152 public void stop() { 153 mLocalAdapter.stopScanning(); 154 mDeviceManager.clearNonBondedDevices(); 155 mLocalManager.getEventManager().unregisterCallback(this); 156 mBondedDevices.clear(); 157 mBondedDevicesSorted.clear(); 158 mAvailableDevices.clear(); 159 mAvailableDevicesSorted.clear(); 160 mSortTask.cancel(true); 161 } 162 163 @Override 164 public BluetoothDeviceListAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, 165 int viewType) { 166 View v; 167 LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); 168 switch (viewType) { 169 case BONDED_DEVICE_HEADER_TYPE: 170 v = layoutInflater.inflate(R.layout.single_text_line_item, parent, false); 171 v.setEnabled(false); 172 ((TextView) v.findViewById(R.id.title)).setText( 173 R.string.bluetooth_preference_paired_devices); 174 break; 175 case AVAILABLE_DEVICE_HEADER_TYPE: 176 v = layoutInflater.inflate(R.layout.single_text_line_item, parent, false); 177 v.setEnabled(false); 178 ((TextView) v.findViewById(R.id.title)).setText( 179 R.string.bluetooth_preference_found_devices); 180 break; 181 default: 182 v = layoutInflater.inflate(R.layout.icon_widget_line_item, parent, false); 183 } 184 return new ViewHolder(v); 185 } 186 187 @Override 188 public int getItemCount() { 189 return mAvailableDevicesSorted.size() + NUM_OF_HEADERS + mBondedDevicesSorted.size(); 190 } 191 192 @Override 193 public void setMaxItems(int maxItems) { 194 // no limit in this list. 195 } 196 197 @Override 198 public void onBindViewHolder(ViewHolder holder, int position) { 199 final CachedBluetoothDevice bluetoothDevice = getItem(position); 200 if (bluetoothDevice == null) { 201 // this row is for in-list headers 202 return; 203 } 204 if (holder.getOldPosition() != RecyclerView.NO_POSITION) { 205 getItem(holder.getOldPosition()).unregisterCallback(holder.mCallback); 206 } 207 bluetoothDevice.registerCallback(holder.mCallback); 208 holder.mTitle.setText(bluetoothDevice.getName()); 209 Pair<Integer, String> pair = getBtClassDrawableWithDescription(bluetoothDevice); 210 holder.mIcon.setImageResource(pair.first); 211 String summaryText = bluetoothDevice.getCarConnectionSummary(); 212 if (summaryText != null) { 213 holder.mDesc.setText(summaryText); 214 holder.mDesc.setVisibility(View.VISIBLE); 215 } else { 216 holder.mDesc.setVisibility(View.GONE); 217 } 218 if (BluetoothDeviceFilter.BONDED_DEVICE_FILTER.matches(bluetoothDevice.getDevice())) { 219 holder.mActionButton.setVisibility(View.VISIBLE); 220 holder.mActionButton.setOnClickListener(v -> { 221 mFragmentController.launchFragment( 222 BluetoothDetailFragment.getInstance(bluetoothDevice.getDevice())); 223 }); 224 } else { 225 holder.mActionButton.setVisibility(View.GONE); 226 } 227 } 228 229 @Override 230 public int getItemViewType(int position) { 231 // the first row is the header for the bonded device list; 232 if (position == 0) { 233 return BONDED_DEVICE_HEADER_TYPE; 234 } 235 // after the end of the bonded device list is the header of the available device list. 236 if (position == mBondedDevicesSorted.size() + 1) { 237 return AVAILABLE_DEVICE_HEADER_TYPE; 238 } 239 return DEVICE_ROW_TYPE; 240 } 241 242 private CachedBluetoothDevice getItem(int position) { 243 if (position > 0 && position <= mBondedDevicesSorted.size()) { 244 // off set the header row 245 return mBondedDevicesSorted.get(position - 1); 246 } 247 if (position > mBondedDevicesSorted.size() + 1 248 && position <= mBondedDevicesSorted.size() + 1 + mAvailableDevicesSorted.size()) { 249 // off set two header row and the size of bonded device list. 250 return mAvailableDevicesSorted.get( 251 position - NUM_OF_HEADERS - mBondedDevicesSorted.size()); 252 } 253 // otherwise it's a in list header 254 return null; 255 } 256 257 // callback functions 258 @Override 259 public void onDeviceAdded(CachedBluetoothDevice cachedDevice) { 260 if (addDevice(cachedDevice)) { 261 ArrayList<CachedBluetoothDevice> devices = new ArrayList<>(mBondedDevices); 262 Collections.sort(devices); 263 mBondedDevicesSorted = devices; 264 notifyDataSetChanged(); 265 } 266 } 267 268 @Override 269 public void onDeviceDeleted(CachedBluetoothDevice cachedDevice) { 270 // the device might changed bonding state, so need to remove from both sets. 271 if (mBondedDevices.remove(cachedDevice)) { 272 mBondedDevicesSorted.remove(cachedDevice); 273 } 274 mAvailableDevices.remove(cachedDevice); 275 notifyDataSetChanged(); 276 } 277 278 @Override 279 public void onBluetoothStateChanged(int bluetoothState) { 280 switch (bluetoothState) { 281 case BluetoothAdapter.STATE_OFF: 282 mBondedDevices.clear(); 283 mBondedDevicesSorted.clear(); 284 mAvailableDevices.clear(); 285 mAvailableDevicesSorted.clear(); 286 notifyDataSetChanged(); 287 break; 288 case BluetoothAdapter.STATE_ON: 289 mLocalAdapter.startScanning(true); 290 addBondDevices(); 291 addCachedDevices(); 292 break; 293 default: 294 } 295 } 296 297 public void reset() { 298 mBondedDevices.clear(); 299 mBondedDevicesSorted.clear(); 300 mAvailableDevices.clear(); 301 mAvailableDevicesSorted.clear(); 302 mLocalAdapter.startScanning(true); 303 addBondDevices(); 304 addCachedDevices(); 305 notifyDataSetChanged(); 306 } 307 308 @Override 309 public void onScanningStateChanged(boolean started) { 310 // don't care 311 } 312 313 @Override 314 public void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState) { 315 onDeviceDeleted(cachedDevice); 316 onDeviceAdded(cachedDevice); 317 } 318 319 /** 320 * Call back for the first connection or the last connection to ANY device/profile. Not 321 * suitable for monitor per device level connection. 322 */ 323 @Override 324 public void onConnectionStateChanged(CachedBluetoothDevice cachedDevice, int state) { 325 onDeviceDeleted(cachedDevice); 326 onDeviceAdded(cachedDevice); 327 } 328 329 @Override 330 public void onActiveDeviceChanged(CachedBluetoothDevice activeDevice, int bluetoothProfile) { 331 // Not used (for now) 332 } 333 334 @Override 335 public void onAudioModeChanged() { 336 // Not used (for now) 337 } 338 339 private void addDevices(Collection<CachedBluetoothDevice> cachedDevices) { 340 boolean needSort = false; 341 for (CachedBluetoothDevice device : cachedDevices) { 342 if (addDevice(device)) { 343 needSort = true; 344 } 345 } 346 if (needSort) { 347 ArrayList<CachedBluetoothDevice> devices = 348 new ArrayList<CachedBluetoothDevice>(mBondedDevices); 349 Collections.sort(devices); 350 mBondedDevicesSorted = devices; 351 notifyDataSetChanged(); 352 } 353 } 354 355 /** 356 * @return {@code true} if list changed and needed sort again. 357 */ 358 private boolean addDevice(CachedBluetoothDevice cachedDevice) { 359 boolean needSort = false; 360 if (BluetoothDeviceFilter.BONDED_DEVICE_FILTER.matches(cachedDevice.getDevice())) { 361 if (mBondedDevices.add(cachedDevice)) { 362 needSort = true; 363 } 364 } 365 if (BluetoothDeviceFilter.UNBONDED_DEVICE_FILTER.matches(cachedDevice.getDevice()) 366 && (mShowDevicesWithoutNames || cachedDevice.hasHumanReadableName())) { 367 // reset is done at SortTask. 368 mAvailableDevices.add(cachedDevice); 369 } 370 return needSort; 371 } 372 373 private void addBondDevices() { 374 Set<BluetoothDevice> bondedDevices = mLocalAdapter.getBondedDevices(); 375 if (bondedDevices == null) { 376 return; 377 } 378 ArrayList<CachedBluetoothDevice> cachedBluetoothDevices = new ArrayList<>(); 379 for (BluetoothDevice device : bondedDevices) { 380 CachedBluetoothDevice cachedDevice = mDeviceManager.findDevice(device); 381 if (cachedDevice == null) { 382 cachedDevice = mDeviceManager.addDevice( 383 mLocalAdapter, mLocalManager.getProfileManager(), device); 384 } 385 cachedBluetoothDevices.add(cachedDevice); 386 } 387 addDevices(cachedBluetoothDevices); 388 } 389 390 private void addCachedDevices() { 391 addDevices(mDeviceManager.getCachedDevicesCopy()); 392 } 393 394 private Pair<Integer, String> getBtClassDrawableWithDescription( 395 CachedBluetoothDevice bluetoothDevice) { 396 BluetoothClass btClass = bluetoothDevice.getBtClass(); 397 if (btClass != null) { 398 switch (btClass.getMajorDeviceClass()) { 399 case BluetoothClass.Device.Major.COMPUTER: 400 return new Pair<>(R.drawable.ic_bt_laptop, mComputerDescription); 401 402 case BluetoothClass.Device.Major.PHONE: 403 return new Pair<>(R.drawable.ic_bt_cellphone, mPhoneDescription); 404 405 case BluetoothClass.Device.Major.PERIPHERAL: 406 return new Pair<>(HidProfile.getHidClassDrawable(btClass), 407 mInputPeripheralDescription); 408 409 case BluetoothClass.Device.Major.IMAGING: 410 return new Pair<>(R.drawable.ic_bt_imaging, mImagingDescription); 411 412 default: 413 // unrecognized device class; continue 414 } 415 } else { 416 LOG.w("btClass is null"); 417 } 418 419 List<LocalBluetoothProfile> profiles = bluetoothDevice.getProfiles(); 420 for (LocalBluetoothProfile profile : profiles) { 421 int resId = profile.getDrawableResource(btClass); 422 if (resId != 0) { 423 return new Pair<Integer, String>(resId, null); 424 } 425 } 426 if (btClass != null) { 427 if (btClass.doesClassMatch(BluetoothClass.PROFILE_HEADSET)) { 428 return new Pair<Integer, String>(R.drawable.ic_bt_headset_hfp, mHeadsetDescription); 429 } 430 if (btClass.doesClassMatch(BluetoothClass.PROFILE_A2DP)) { 431 return new Pair<Integer, String>(R.drawable.ic_bt_headphones_a2dp, 432 mHeadphoneDescription); 433 } 434 } 435 return new Pair<Integer, String>(R.drawable.ic_settings_bluetooth, mBluetoothDescription); 436 } 437 438 /** 439 * Updates device render upon device attribute change. 440 */ 441 // TODO: This is a walk around for handling attribute callback. Since the callback doesn't 442 // contain the information about which device needs to be updated, we have to maintain a 443 // local reference to the device. Fix the code in CachedBluetoothDevice.Callback to return 444 // a reference of the device been updated. 445 private class DeviceAttributeChangeCallback implements CachedBluetoothDevice.Callback { 446 447 private final ViewHolder mViewHolder; 448 449 DeviceAttributeChangeCallback(ViewHolder viewHolder) { 450 mViewHolder = viewHolder; 451 } 452 453 @Override 454 public void onDeviceAttributesChanged() { 455 notifyItemChanged(mViewHolder.getAdapterPosition()); 456 } 457 } 458 459 private class BluetoothClickListener implements OnClickListener { 460 private final ViewHolder mViewHolder; 461 462 BluetoothClickListener(ViewHolder viewHolder) { 463 mViewHolder = viewHolder; 464 } 465 466 @Override 467 public void onClick(View v) { 468 CachedBluetoothDevice device = getItem(mViewHolder.getAdapterPosition()); 469 int bondState = device.getBondState(); 470 471 if (device.isConnected()) { 472 // TODO: ask user for confirmation 473 device.disconnect(); 474 } else if (bondState == BluetoothDevice.BOND_BONDED) { 475 device.connect(true); 476 } else if (bondState == BluetoothDevice.BOND_NONE) { 477 if (!device.startPairing()) { 478 showError(device.getName(), 479 R.string.bluetooth_pairing_error_message); 480 return; 481 } 482 // allow MAP and PBAP since this is client side, permission should be handled on 483 // server side. i.e. the phone side. 484 device.setPhonebookPermissionChoice(CachedBluetoothDevice.ACCESS_ALLOWED); 485 device.setMessagePermissionChoice(CachedBluetoothDevice.ACCESS_ALLOWED); 486 } 487 } 488 } 489 490 private void showError(String name, int messageResId) { 491 String message = mContext.getString(messageResId, name); 492 Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show(); 493 } 494 495 /** 496 * Provides an ordered bt device list periodically. 497 */ 498 // TODO: improve the way we sort BT devices. Ideally we should keep all devices in a TreeSet 499 // and as devices are added the correct order is maintained, that requires a consistent 500 // logic between equals and compareTo function, unfortunately it's not the case in 501 // CachedBluetoothDevice class. Fix that and improve the way we order devices. 502 private class SortTask extends AsyncTask<Void, Void, ArrayList<CachedBluetoothDevice>> { 503 504 /** 505 * Returns {code null} if no changed are made. 506 */ 507 @Override 508 protected ArrayList<CachedBluetoothDevice> doInBackground(Void... v) { 509 if (mAvailableDevicesSorted != null 510 && mAvailableDevicesSorted.size() == mAvailableDevices.size()) { 511 return null; 512 } 513 ArrayList<CachedBluetoothDevice> devices = 514 new ArrayList<CachedBluetoothDevice>(mAvailableDevices); 515 Collections.sort(devices); 516 return devices; 517 } 518 519 @Override 520 protected void onPostExecute(ArrayList<CachedBluetoothDevice> devices) { 521 // skip if no changes are made. 522 if (devices != null) { 523 mAvailableDevicesSorted = devices; 524 notifyDataSetChanged(); 525 } 526 mHandler.postDelayed(new Runnable() { 527 public void run() { 528 mSortTask = new SortTask(); 529 mSortTask.execute(); 530 } 531 }, DELAY_MILLIS); 532 } 533 } 534 } 535