/* * Copyright (C) 2017 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.annotation.NonNull; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothClass; import android.bluetooth.BluetoothDevice; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.os.AsyncTask; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.util.Pair; import android.support.car.ui.PagedListView; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; import com.android.car.settings.R; import com.android.settingslib.bluetooth.BluetoothCallback; import com.android.settingslib.bluetooth.BluetoothDeviceFilter; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; import com.android.settingslib.bluetooth.LocalBluetoothAdapter; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.bluetooth.LocalBluetoothProfile; import com.android.settingslib.bluetooth.HidProfile; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; /** * Renders {@link android.bluetooth.BluetoothDevice} to a view to be displayed as a row in a list. */ public class BluetoothDeviceListAdapter extends RecyclerView.Adapter implements PagedListView.ItemCap, BluetoothCallback { private static final String TAG = "BluetoothDeviceListAdapter"; private static final int DEVICE_ROW_TYPE = 1; private static final int BONDED_DEVICE_HEADER_TYPE = 2; private static final int AVAILABLE_DEVICE_HEADER_TYPE = 3; private static final int NUM_OF_HEADERS = 2; public static final int DELAY_MILLIS = 1000; private final Handler mHandler = new Handler(Looper.getMainLooper()); private final HashSet mBondedDevices = new HashSet<>(); private final HashSet mAvailableDevices = new HashSet<>(); private final LocalBluetoothAdapter mLocalAdapter; private final LocalBluetoothManager mLocalManager; private final CachedBluetoothDeviceManager mDeviceManager; private final Context mContext; /* Talk-back descriptions for various BT icons */ public final String mComputerDescription; public final String mInputPeripheralDescription; public final String mHeadsetDescription; public final String mPhoneDescription; public final String mImagingDescription; public final String mHeadphoneDescription; public final String mBluetoothDescription; private SortTask mSortTask = new SortTask(); private ArrayList mBondedDevicesSorted = new ArrayList<>(); private ArrayList mAvailableDevicesSorted = new ArrayList<>(); class ViewHolder extends RecyclerView.ViewHolder { private final ImageView mIcon; private final TextView mTitle; private final TextView mDesc; private final ImageButton mActionButton; private final DeviceAttributeChangeCallback mCallback = new DeviceAttributeChangeCallback(this); public ViewHolder(View view) { super(view); mTitle = (TextView) view.findViewById(R.id.title); mDesc = (TextView) view.findViewById(R.id.desc); mIcon = (ImageView) view.findViewById(R.id.icon); mActionButton = (ImageButton) view.findViewById(R.id.action); view.setOnClickListener(new BluetoothClickListener(this)); } } public BluetoothDeviceListAdapter( Context context, LocalBluetoothManager localBluetoothManager) { mContext = context; mLocalManager = localBluetoothManager; mLocalAdapter = mLocalManager.getBluetoothAdapter(); mDeviceManager = mLocalManager.getCachedDeviceManager(); Resources r = context.getResources(); mComputerDescription = r.getString(R.string.bluetooth_talkback_computer); mInputPeripheralDescription = r.getString( R.string.bluetooth_talkback_input_peripheral); mHeadsetDescription = r.getString(R.string.bluetooth_talkback_headset); mPhoneDescription = r.getString(R.string.bluetooth_talkback_phone); mImagingDescription = r.getString(R.string.bluetooth_talkback_imaging); mHeadphoneDescription = r.getString(R.string.bluetooth_talkback_headphone); mBluetoothDescription = r.getString(R.string.bluetooth_talkback_bluetooth); } public void start() { mLocalManager.getEventManager().registerCallback(this); if (mLocalAdapter.isEnabled()) { mLocalAdapter.startScanning(true); addBondDevices(); addCachedDevices(); } mSortTask.execute(); } public void stop() { mLocalAdapter.stopScanning(); mDeviceManager.clearNonBondedDevices(); mLocalManager.getEventManager().unregisterCallback(this); mBondedDevices.clear(); mAvailableDevices.clear(); mSortTask.cancel(true); } @Override public BluetoothDeviceListAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View v; LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); switch (viewType) { case BONDED_DEVICE_HEADER_TYPE: v = layoutInflater.inflate(R.layout.in_list_header, parent, false); ((TextView) v).setText(R.string.bluetooth_preference_paired_devices); break; case AVAILABLE_DEVICE_HEADER_TYPE: v = layoutInflater.inflate(R.layout.in_list_header, parent, false); ((TextView) v).setText(R.string.bluetooth_preference_found_devices); break; default: v = layoutInflater.inflate(R.layout.list_item, parent, false); } return new ViewHolder(v); } @Override public int getItemCount() { return mAvailableDevicesSorted.size() + NUM_OF_HEADERS + mBondedDevicesSorted.size(); } @Override public void setMaxItems(int maxItems) { // no limit in this list. } @Override public void onBindViewHolder(ViewHolder holder, int position) { final CachedBluetoothDevice bluetoothDevice = getItem(position); if (bluetoothDevice == null) { // this row is for in-list headers return; } if (holder.getOldPosition() != RecyclerView.NO_POSITION) { getItem(holder.getOldPosition()).unregisterCallback(holder.mCallback); } bluetoothDevice.registerCallback(holder.mCallback); holder.mTitle.setText(bluetoothDevice.getName()); Pair pair = getBtClassDrawableWithDescription(bluetoothDevice); holder.mIcon.setImageResource(pair.first); int summaryResourceId = bluetoothDevice.getConnectionSummary(); if (summaryResourceId != 0) { holder.mDesc.setText(summaryResourceId); holder.mDesc.setVisibility(View.VISIBLE); } else { holder.mDesc.setVisibility(View.GONE); } if (BluetoothDeviceFilter.BONDED_DEVICE_FILTER.matches(bluetoothDevice.getDevice())) { holder.mActionButton.setVisibility(View.VISIBLE); holder.mActionButton.setOnClickListener(v -> { Intent intent = new Intent(mContext, BluetoothDetailActivity.class); intent.putExtra( BluetoothDetailActivity.BT_DEVICE_KEY, bluetoothDevice.getDevice()); mContext.startActivity(intent); }); } else { holder.mActionButton.setVisibility(View.GONE); } } @Override public int getItemViewType(int position) { // the first row is the header for the bonded device list; if (position == 0) { return BONDED_DEVICE_HEADER_TYPE; } // after the end of the bonded device list is the header of the available device list. if (position == mBondedDevicesSorted.size() + 1) { return AVAILABLE_DEVICE_HEADER_TYPE; } return DEVICE_ROW_TYPE; } private CachedBluetoothDevice getItem(int position) { if (position > 0 && position <= mBondedDevicesSorted.size()) { // off set the header row return mBondedDevicesSorted.get(position - 1); } if (position > mBondedDevicesSorted.size() + 1 && position <= mBondedDevicesSorted.size() + 1 + mAvailableDevicesSorted.size()) { // off set two header row and the size of bonded device list. return mAvailableDevicesSorted.get( position - NUM_OF_HEADERS - mBondedDevicesSorted.size()); } // otherwise it's a in list header return null; } // callback functions @Override public void onDeviceAdded(CachedBluetoothDevice cachedDevice) { if (addDevice(cachedDevice)) { ArrayList devices = new ArrayList<>(mBondedDevices); Collections.sort(devices); mBondedDevicesSorted = devices; notifyDataSetChanged(); } } @Override public void onDeviceDeleted(CachedBluetoothDevice cachedDevice) { onDeviceDeleted(cachedDevice, true /* refresh */); } @Override public void onBluetoothStateChanged(int bluetoothState) { switch (bluetoothState) { case BluetoothAdapter.STATE_OFF: mBondedDevices.clear(); mBondedDevicesSorted.clear(); mAvailableDevices.clear(); mAvailableDevicesSorted.clear(); notifyDataSetChanged(); break; case BluetoothAdapter.STATE_ON: mLocalAdapter.startScanning(true); addBondDevices(); addCachedDevices(); break; default: } } @Override public void onScanningStateChanged(boolean started) { // don't care } @Override public void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState) { onDeviceDeleted(cachedDevice, false /* refresh */); onDeviceAdded(cachedDevice); } /** * Call back for the first connection or the last connection to ANY device/profile. Not * suitable for monitor per device level connection. */ @Override public void onConnectionStateChanged(CachedBluetoothDevice cachedDevice, int state) { onDeviceDeleted(cachedDevice, false); onDeviceAdded(cachedDevice); } private void onDeviceDeleted(CachedBluetoothDevice cachedDevice, boolean refresh) { // the device might changed bonding state, so need to remove from both sets. if (mBondedDevices.remove(cachedDevice)) { mBondedDevicesSorted.remove(cachedDevice); } mAvailableDevices.remove(cachedDevice); if (refresh) { notifyDataSetChanged(); } } private void addDevices(Collection cachedDevices) { boolean needSort = false; for (CachedBluetoothDevice device : cachedDevices) { if (addDevice(device)) { needSort = true; } } if (needSort) { ArrayList devices = new ArrayList(mBondedDevices); Collections.sort(devices); mBondedDevicesSorted = devices; notifyDataSetChanged(); } } /** * @return {@code true} if list changed and needed sort again. */ private boolean addDevice(CachedBluetoothDevice cachedDevice) { boolean needSort = false; if (BluetoothDeviceFilter.BONDED_DEVICE_FILTER.matches(cachedDevice.getDevice())) { if (mBondedDevices.add(cachedDevice)) { needSort = true; } } if (BluetoothDeviceFilter.UNBONDED_DEVICE_FILTER.matches(cachedDevice.getDevice())) { // refresh is done at SortTask. mAvailableDevices.add(cachedDevice); } return needSort; } private void addBondDevices() { Set bondedDevices = mLocalAdapter.getBondedDevices(); if (bondedDevices == null) { return; } ArrayList cachedBluetoothDevices = new ArrayList<>(); for (BluetoothDevice device : bondedDevices) { CachedBluetoothDevice cachedDevice = mDeviceManager.findDevice(device); if (cachedDevice == null) { cachedDevice = mDeviceManager.addDevice( mLocalAdapter, mLocalManager.getProfileManager(), device); } cachedBluetoothDevices.add(cachedDevice); } addDevices(cachedBluetoothDevices); } private void addCachedDevices() { addDevices(mDeviceManager.getCachedDevicesCopy()); } private Pair getBtClassDrawableWithDescription( CachedBluetoothDevice bluetoothDevice) { BluetoothClass btClass = bluetoothDevice.getBtClass(); if (btClass != null) { switch (btClass.getMajorDeviceClass()) { case BluetoothClass.Device.Major.COMPUTER: return new Pair<>(R.drawable.ic_bt_laptop, mComputerDescription); case BluetoothClass.Device.Major.PHONE: return new Pair<>(R.drawable.ic_bt_cellphone, mPhoneDescription); case BluetoothClass.Device.Major.PERIPHERAL: return new Pair<>(HidProfile.getHidClassDrawable(btClass), mInputPeripheralDescription); case BluetoothClass.Device.Major.IMAGING: return new Pair<>(R.drawable.ic_bt_imaging, mImagingDescription); default: // unrecognized device class; continue } } else { Log.w(TAG, "btClass is null"); } List profiles = bluetoothDevice.getProfiles(); for (LocalBluetoothProfile profile : profiles) { int resId = profile.getDrawableResource(btClass); if (resId != 0) { return new Pair(resId, null); } } if (btClass != null) { if (btClass.doesClassMatch(BluetoothClass.PROFILE_HEADSET)) { return new Pair(R.drawable.ic_bt_headset_hfp, mHeadsetDescription); } if (btClass.doesClassMatch(BluetoothClass.PROFILE_A2DP)) { return new Pair(R.drawable.ic_bt_headphones_a2dp, mHeadphoneDescription); } } return new Pair(R.drawable.ic_settings_bluetooth, mBluetoothDescription); } /** * Updates device render upon device attribute change. */ // TODO: This is a walk around for handling attribute callback. Since the callback doesn't // contain the information about which device needs to be updated, we have to maintain a // local reference to the device. Fix the code in CachedBluetoothDevice.Callback to return // a reference of the device been updated. private class DeviceAttributeChangeCallback implements CachedBluetoothDevice.Callback { private final ViewHolder mViewHolder; DeviceAttributeChangeCallback(ViewHolder viewHolder) { mViewHolder = viewHolder; } @Override public void onDeviceAttributesChanged() { notifyItemChanged(mViewHolder.getAdapterPosition()); } } private class BluetoothClickListener implements OnClickListener { private final ViewHolder mViewHolder; BluetoothClickListener(ViewHolder viewHolder) { mViewHolder = viewHolder; } @Override public void onClick(View v) { CachedBluetoothDevice device = getItem(mViewHolder.getAdapterPosition()); int bondState = device.getBondState(); if (device.isConnected()) { // TODO: ask user for confirmation device.disconnect(); } else if (bondState == BluetoothDevice.BOND_BONDED) { device.connect(true); } else if (bondState == BluetoothDevice.BOND_NONE) { if (!device.startPairing()) { showError(device.getName(), R.string.bluetooth_pairing_error_message); } } } } private void showError(String name, int messageResId) { String message = mContext.getString(messageResId, name); Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show(); } /** * Provides an ordered bt device list periodically. */ // TODO: improve the way we sort BT devices. Ideally we should keep all devices in a TreeSet // and as devices are added the correct order is maintained, that requires a consistent // logic between equals and compareTo function, unfortunately it's not the case in // CachedBluetoothDevice class. Fix that and improve the way we order devices. private class SortTask extends AsyncTask> { /** * Returns {code null} if no changed are made. */ @Override protected ArrayList doInBackground(Void... v) { if (mAvailableDevicesSorted != null && mAvailableDevicesSorted.size() == mAvailableDevices.size()) { return null; } ArrayList devices = new ArrayList(mAvailableDevices); Collections.sort(devices); return devices; } @Override protected void onPostExecute(ArrayList devices) { // skip if no changes are made. if (devices != null) { mAvailableDevicesSorted = devices; notifyDataSetChanged(); } mHandler.postDelayed(new Runnable() { public void run() { mSortTask = new SortTask(); mSortTask.execute(); } }, DELAY_MILLIS); } } }