From e7a8c897a3fb9fbba36945a5a37dda43b12d4d15 Mon Sep 17 00:00:00 2001 From: Dennis Guse Date: Tue, 12 Nov 2019 18:30:47 +0100 Subject: [PATCH] Settings: scan for Bluetooth sensors rather than using bonded devices. Bonded devices do no provide information what services (e.g., heart rate) they support. --- .../sensors/BluetoothConnectionManager.java | 5 +- .../settings/BluetoothLeAdapter.java | 112 ++++++++++ .../settings/BluetoothLeListPreference.java | 200 ++++++++++++++++++ .../opentracks/settings/PreferenceHelper.java | 37 ---- .../opentracks/settings/SettingsActivity.java | 42 +++- .../opentracks/util/BluetoothUtils.java | 26 +-- src/main/res/values/strings.xml | 10 +- src/main/res/xml/settings.xml | 5 +- 8 files changed, 358 insertions(+), 79 deletions(-) create mode 100644 src/main/java/de/dennisguse/opentracks/settings/BluetoothLeAdapter.java create mode 100644 src/main/java/de/dennisguse/opentracks/settings/BluetoothLeListPreference.java diff --git a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java index 4367a1850..32578a14c 100644 --- a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java +++ b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java @@ -45,7 +45,7 @@ public class BluetoothConnectionManager { static final int MESSAGE_READ = 3; static final int MESSAGE_DISCONNECTED = 4; - private static final UUID HEART_RATE_SERVICE_UUID = new UUID(0x180D00001000L, 0x800000805f9b34fbL); + public static final UUID HEART_RATE_SERVICE_UUID = new UUID(0x180D00001000L, 0x800000805f9b34fbL); private static final UUID HEART_RATE_MEASUREMENT_CHAR_UUID = new UUID(0x2A3700001000L, 0x800000805f9b34fbL); private static final UUID CLIENT_CHARACTERISTIC_CONFIG_UUID = new UUID(0x290200001000L, 0x800000805f9b34fbL); @@ -131,8 +131,9 @@ public class BluetoothConnectionManager { Log.d(TAG, "Connecting to: " + bluetoothDevice); - setState(SensorState.CONNECTING); bluetoothGatt = bluetoothDevice.connectGatt(this.context, true, this.connectCallback); + + setState(SensorState.CONNECTING); } public synchronized void disconnect() { diff --git a/src/main/java/de/dennisguse/opentracks/settings/BluetoothLeAdapter.java b/src/main/java/de/dennisguse/opentracks/settings/BluetoothLeAdapter.java new file mode 100644 index 000000000..069c15552 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/settings/BluetoothLeAdapter.java @@ -0,0 +1,112 @@ +package de.dennisguse.opentracks.settings; + +import android.bluetooth.BluetoothDevice; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class BluetoothLeAdapter extends BaseAdapter { + + /** + * Contains a unique list (by address) of devices. + */ + private final List devices = new ArrayList<>(); + + @Override + public int getCount() { + return devices.size(); + } + + @Override + public Object getItem(int position) { + return devices.get(position); + } + + @Override + public long getItemId(int position) { + return devices.get(position).hashCode(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View currentView = convertView; + if (convertView == null) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + //TODO Check if there is a better way to achieve identical look and feel to ListPreference. + //Use material design single choice; for old style use: android.R.layout.select_dialog_singlechoice + currentView = inflater.inflate(androidx.appcompat.R.layout.select_dialog_singlechoice_material, null); + } + + Device device = devices.get(position); + TextView textView = currentView.findViewById(android.R.id.text1); + textView.setText(device.getName()); + + return currentView; + } + + public void add(String name, String address) { + Device device = new Device(name, address); + if (!devices.contains(device)) { + devices.add(new Device(name, address)); + } else { + for (Device currentDevice : devices) { + if (currentDevice.getAddress().equals(address)) { + currentDevice.setName(name); + } + } + } + notifyDataSetChanged(); + } + + public void add(BluetoothDevice bluetoothDevice) { + add(bluetoothDevice.getName(), bluetoothDevice.getAddress()); + } + + public Device get(int index) { + return devices.get(index); + } + + public class Device { + private String name; + private String address; + + public Device(String name, String address) { + this.name = name; + this.address = address; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAddress() { + return address; + } + + /** + * Check if the address is identical. + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Device)) return false; + Device device = (Device) o; + return address.equals(device.address); + } + + @Override + public int hashCode() { + return Objects.hash(name, address); + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/BluetoothLeListPreference.java b/src/main/java/de/dennisguse/opentracks/settings/BluetoothLeListPreference.java new file mode 100644 index 000000000..cd5413be9 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/settings/BluetoothLeListPreference.java @@ -0,0 +1,200 @@ +package de.dennisguse.opentracks.settings; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.ParcelUuid; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.preference.DialogPreference; +import androidx.preference.PreferenceDialogFragmentCompat; + +import java.util.ArrayList; +import java.util.List; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.services.sensors.BluetoothConnectionManager; +import de.dennisguse.opentracks.util.BluetoothUtils; + +/** + * Preference to select a discoverable Bluetooth LE device. + * Based upon ListPreference. + */ +public class BluetoothLeListPreference extends DialogPreference { + + private final static String TAG = BluetoothLeListPreference.class.getSimpleName(); + + public BluetoothLeListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BluetoothLeListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BluetoothLeListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BluetoothLeListPreference(Context context) { + super(context); + } + + private String value; + private boolean valueSet = false; + + public String getValue() { + return value; + } + + public void setValue(String value) { + final boolean changed = !TextUtils.equals(this.value, value); + if (changed || !valueSet) { + this.value = value; + valueSet = true; + persistString(value); + if (changed) { + notifyChanged(); + } + } + } + + @Override + protected void onSetInitialValue(Object defaultValue) { + setValue(getPersistedString((String) defaultValue)); + } + + @Override + public CharSequence getSummary() { + return getValue(); + } + + public static class BluetoothLeListPreferenceDialog extends PreferenceDialogFragmentCompat { + + private int selectedEntryIndex; + private BluetoothLeAdapter listAdapter = new BluetoothLeAdapter(); + + private BluetoothLeScanner scanner = null; + private ScanCallback scanCallback = new ScanCallback() { + @Override + public void onScanResult(int callbackType, ScanResult result) { + super.onScanResult(callbackType, result); + Log.d(TAG, "Found device " + result.getDevice().getName() + " " + result); + + listAdapter.add(result.getDevice()); + } + + @Override + public void onBatchScanResults(List results) { + for (ScanResult scanResult : results) { + listAdapter.add(scanResult.getDevice()); + } + } + + @Override + public void onScanFailed(int errorCode) { + super.onScanFailed(errorCode); + Log.e(TAG, "Bluetooth scan failed with errorCode " + errorCode); + Toast.makeText(getContext(), R.string.sensor_could_not_scan, Toast.LENGTH_LONG).show(); + dismiss(); + } + }; + + static BluetoothLeListPreferenceDialog newInstance(String key) { + final BluetoothLeListPreferenceDialog fragment = new BluetoothLeListPreferenceDialog(); + final Bundle b = new Bundle(1); + b.putString(ARG_KEY, key); + fragment.setArguments(b); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG); + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { + Log.w(TAG, "Bluetooth adapter is present or not enabled."); + Toast.makeText(getContext(), R.string.bluetooth_disabled, Toast.LENGTH_SHORT).show(); + dismiss(); + return; + } + + if (bluetoothAdapter.isDiscovering()) { + Log.i(TAG, "Cancelling ongoing bluetooth discovery."); + bluetoothAdapter.cancelDiscovery(); + } + + scanner = bluetoothAdapter.getBluetoothLeScanner(); + if (scanner == null) { + Log.e(TAG, "BluetoothLeScanner is null."); + dismiss(); + return; + } + + String deviceNone = getContext().getString(R.string.sensor_type_value_none); + listAdapter.add(getContext().getString(R.string.value_none), deviceNone); + selectedEntryIndex = 0; + + BluetoothLeListPreference preference = (BluetoothLeListPreference) getPreference(); + String deviceSelected = preference.getValue(); + if (!deviceNone.equals(deviceSelected)) { + listAdapter.add(preference.getValue(), preference.getValue()); + selectedEntryIndex = 1; + } + + ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder().setServiceUuid(new ParcelUuid(BluetoothConnectionManager.HEART_RATE_SERVICE_UUID)); + List scanFilter = new ArrayList<>(); + scanFilter.add(scanFilterBuilder.build()); + + ScanSettings.Builder scanSettingsBuilder = new ScanSettings.Builder(); + scanSettingsBuilder.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY); + + scanner.startScan(scanFilter, scanSettingsBuilder.build(), scanCallback); + } + + //Behave like ListPreferenceDialogFragmentCompat, but uses a custom listAdapter. + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + super.onPrepareDialogBuilder(builder); + + builder.setSingleChoiceItems(listAdapter, selectedEntryIndex, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + selectedEntryIndex = which; + + BluetoothLeListPreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE); + dialog.dismiss(); + } + }); + builder.setIcon(android.R.drawable.stat_sys_data_bluetooth); + builder.setPositiveButton(null, null); + } + + @Override + public void onDialogClosed(boolean positiveResult) { + if (scanner != null) { + scanner.stopScan(scanCallback); + } + + if (positiveResult && selectedEntryIndex >= 0) { + String value = listAdapter.get(selectedEntryIndex).getAddress(); + BluetoothLeListPreference preference = (BluetoothLeListPreference) getPreference(); + if (preference.callChangeListener(value)) { + preference.setValue(value); + } + } + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/PreferenceHelper.java b/src/main/java/de/dennisguse/opentracks/settings/PreferenceHelper.java index c587a7665..d5a587fc4 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/PreferenceHelper.java +++ b/src/main/java/de/dennisguse/opentracks/settings/PreferenceHelper.java @@ -1,15 +1,8 @@ package de.dennisguse.opentracks.settings; -import android.bluetooth.BluetoothAdapter; import android.content.Context; -import androidx.preference.ListPreference; - -import java.util.ArrayList; -import java.util.List; - import de.dennisguse.opentracks.R; -import de.dennisguse.opentracks.util.BluetoothUtils; import de.dennisguse.opentracks.util.PreferencesUtils; import de.dennisguse.opentracks.util.UnitConversions; @@ -167,34 +160,4 @@ final class PreferenceHelper { return entries; } - - /** - * Configures the bluetooth sensor. - */ - static void configureBluetoothSensorList(ListPreference preference) { - Context context = preference.getContext(); - - String value = PreferencesUtils.getString(context, R.string.settings_sensor_bluetooth_sensor_key, PreferencesUtils.BLUETOOTH_SENSOR_DEFAULT); - List devicesNameList = new ArrayList<>(); - List devicesAddressList = new ArrayList<>(); - BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); - if (bluetoothAdapter != null) { - BluetoothUtils.populateDeviceLists(bluetoothAdapter, devicesNameList, devicesAddressList); - } - - // Was the previously configured device unpaired? Then forget it. - if (!devicesAddressList.contains(value)) { - value = PreferencesUtils.BLUETOOTH_SENSOR_DEFAULT; - PreferencesUtils.setString(context, R.string.settings_sensor_bluetooth_sensor_key, value); - } - - devicesNameList.add(0, context.getString(R.string.value_none)); - devicesAddressList.add(0, PreferencesUtils.BLUETOOTH_SENSOR_DEFAULT); - - String[] values = devicesAddressList.toArray(new String[0]); - preference.setEntryValues(values); - - String[] options = devicesNameList.toArray(new String[0]); - preference.setEntries(options); - } } diff --git a/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java b/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java index c042dec89..f639b6718 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java +++ b/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java @@ -13,12 +13,15 @@ import androidx.preference.PreferenceFragmentCompat; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment; +import de.dennisguse.opentracks.util.BluetoothUtils; import de.dennisguse.opentracks.util.HackUtils; import de.dennisguse.opentracks.util.PreferencesUtils; import de.dennisguse.opentracks.util.StringUtils; public class SettingsActivity extends AppCompatActivity implements ChooseActivityTypeDialogFragment.ChooseActivityTypeCaller, ResetDialogPreference.ResetCallback { + private static final String TAG = SettingsActivity.class.getSimpleName(); + private PrefsFragment prefsFragment; @Override @@ -54,13 +57,14 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new SharedPreferences.OnSharedPreferenceChangeListener() { @Override public void onSharedPreferenceChanged(SharedPreferences preferences, String key) { - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - updateUI(); - } - }); - + if (PreferencesUtils.isKey(getActivity(), R.string.recording_track_id_key, key)) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + updateReset(); + } + }); + } if (PreferencesUtils.isKey(getActivity(), R.string.stats_units_key, key)) { getActivity().runOnUiThread(new Runnable() { @Override @@ -69,6 +73,14 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit } }); } + if (PreferencesUtils.isKey(getActivity(), R.string.chart_show_speed_key, key)) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + updateSpeed(); + } + }); + } } }; @@ -93,7 +105,8 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit public void onResume() { super.onResume(); PreferencesUtils.getSharedPreferences(getActivity()).registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); - updateUI(); + updateReset(); + updateBluetooth(); } @Override @@ -110,6 +123,8 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit } else if (preference instanceof ActivityTypePreference) { activityPreferenceDialog = ActivityTypePreference.ActivityPreferenceDialog.newInstance(preference.getKey()); dialogFragment = activityPreferenceDialog; + } else if (preference instanceof BluetoothLeListPreference) { + dialogFragment = BluetoothLeListPreference.BluetoothLeListPreferenceDialog.newInstance(preference.getKey()); } if (dialogFragment != null) { @@ -127,17 +142,22 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit } } - private void updateUI() { + private void updateReset() { final boolean isRecording = PreferencesUtils.isRecording(getActivity()); Preference resetPreference = findPreference(getString(R.string.settings_reset_key)); resetPreference.setSummary(isRecording ? getString(R.string.settings_not_while_recording) : ""); resetPreference.setEnabled(!isRecording); + } + private void updateSpeed() { Preference speedCheckBoxPreference = findPreference(getString(R.string.chart_show_speed_key)); speedCheckBoxPreference.setTitle(PreferencesUtils.isReportSpeed(getActivity()) ? R.string.stats_speed : R.string.stats_pace); + } - ListPreference bluetoothPreference = findPreference(getString(R.string.settings_sensor_bluetooth_sensor_key)); - PreferenceHelper.configureBluetoothSensorList(bluetoothPreference); + private void updateBluetooth() { + // Disable Bluetooth preference if device does not have Bluetooth + BluetoothLeListPreference bluetoothPreference = findPreference(getString(R.string.settings_sensor_bluetooth_sensor_key)); + bluetoothPreference.setVisible(BluetoothUtils.hasBluetooth(TAG)); } private void updateUnits() { diff --git a/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java b/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java index 28b35f54a..081a2df64 100644 --- a/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java +++ b/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java @@ -16,16 +16,12 @@ package de.dennisguse.opentracks.util; import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothClass; -import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGattCharacteristic; import android.os.Handler; import android.os.Looper; import android.util.Log; import java.util.ArrayList; -import java.util.List; -import java.util.Set; /** * Utilities for dealing with bluetooth devices. @@ -74,26 +70,8 @@ public class BluetoothUtils { return adapters.get(0); } - /** - * Populates the device names and the device addresses with all the suitable - * bluetooth devices. - * - * @param bluetoothAdapter the bluetooth adapter - * @param deviceNames list of device names - * @param deviceAddresses list of device addresses - */ - public static void populateDeviceLists(BluetoothAdapter bluetoothAdapter, List deviceNames, List deviceAddresses) { - // Ensure the bluetooth adapter is not in discovery mode. - bluetoothAdapter.cancelDiscovery(); - - Set pairedDevices = bluetoothAdapter.getBondedDevices(); - for (BluetoothDevice device : pairedDevices) { - BluetoothClass bluetoothClass = device.getBluetoothClass(); - if (bluetoothClass != null && (bluetoothClass.getDeviceClass() & BluetoothClass.Device.HEALTH_PULSE_RATE) != 0) { - deviceAddresses.add(device.getAddress()); - deviceNames.add(device.getName()); - } - } + public static boolean hasBluetooth(final String TAG) { + return BluetoothUtils.getDefaultBluetoothAdapter(TAG) != null; } public static int parseHeartRate(BluetoothGattCharacteristic characteristic) { diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 9292076f8..2e26ef035 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1055,7 +1055,13 @@ limitations under the License. Sending - Sensor %1$s is not known. Please check the settings. + Sensor %1$s is unknown. Please check the settings. + + + Could not scan for Bluetooth devices (error %1$i). + + + Please enable Bluetooth. - Sorry, but an error occurred: and the all settings were reset to there default values. + Sorry, but an error occurred. All settings have been reverted to the default values. diff --git a/src/main/res/xml/settings.xml b/src/main/res/xml/settings.xml index b8086a25d..220d95ab0 100644 --- a/src/main/res/xml/settings.xml +++ b/src/main/res/xml/settings.xml @@ -130,11 +130,10 @@ limitations under the License. - + android:title="@string/settings_sensor_bluetooth_sensor" />