From e078ceff0a0bc77828369bc98ba24eb1339d05a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Thu, 31 Aug 2023 22:23:10 +0100 Subject: [PATCH] Introduce DiscoveryActivityV2 --- app/src/main/AndroidManifest.xml | 4 + .../gadgetbridge/GBApplication.java | 4 + .../activities/ControlCenterv2.java | 9 +- .../discovery/DiscoveryActivity.java | 2 + .../discovery/DiscoveryActivityV2.java | 883 ++++++++++++++++++ .../activities/discovery/GBScanEvent.java | 53 ++ .../discovery/GBScanEventProcessor.java | 284 ++++++ .../adapter/DeviceCandidateAdapter.java | 2 +- .../lenovo/LenovoWatchPairingActivity.java | 8 +- .../devices/miband/MiBandPairingActivity.java | 7 +- .../devices/pebble/PebblePairingActivity.java | 7 +- .../devices/watch9/Watch9PairingActivity.java | 8 +- .../gadgetbridge/impl/GBDeviceCandidate.java | 114 ++- .../gadgetbridge/util/DeviceHelper.java | 1 + .../main/res/layout/activity_discovery.xml | 2 +- app/src/main/res/values/strings.xml | 2 + .../res/xml/discovery_pairing_preferences.xml | 7 + 17 files changed, 1366 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 36e4c1d5a..fbb06158c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -530,6 +530,10 @@ android:name=".activities.discovery.DiscoveryActivity" android:label="@string/title_activity_discovery" android:parentActivityName=".activities.ControlCenterv2" /> + diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java index db06324c2..375611bab 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java @@ -1389,6 +1389,10 @@ public class GBApplication extends Application { return prefs; } + public static boolean useNewDiscoveryActivity() { + return prefs.getBoolean("new_discover_activity", true); + } + public static GBPrefs getGBPrefs() { return gbPrefs; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java index e5b0bb5e5..4f80afdc8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java @@ -85,6 +85,7 @@ import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2; import nodomain.freeyourgadget.gadgetbridge.adapter.GBDeviceAdapterv2; import nodomain.freeyourgadget.gadgetbridge.database.DBAccess; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; @@ -375,7 +376,7 @@ public class ControlCenterv2 extends AppCompatActivity GBApplication.deviceService().start(); if (GB.isBluetoothEnabled() && deviceList.isEmpty() && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - startActivity(new Intent(this, DiscoveryActivity.class)); + launchDiscoveryActivity(); } else { GBApplication.deviceService().requestDeviceInfo(); } @@ -482,7 +483,11 @@ public class ControlCenterv2 extends AppCompatActivity } private void launchDiscoveryActivity() { - startActivity(new Intent(this, DiscoveryActivity.class)); + if (GBApplication.useNewDiscoveryActivity()) { + startActivity(new Intent(this, DiscoveryActivityV2.class)); + } else { + startActivity(new Intent(this, DiscoveryActivity.class)); + } } private void refreshPairedDevices() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivity.java index 022be0c3e..3bbd96dd8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivity.java @@ -401,6 +401,8 @@ public class DiscoveryActivity extends AbstractGBActivity implements AdapterView } GBDeviceCandidate candidate = new GBDeviceCandidate(device, rssi, uuids); + candidate.refreshNameIfUnknown(); + candidate.addUuids(device.getUuids()); DeviceType deviceType = DeviceHelper.getInstance().getSupportedType(candidate); if (deviceType.isSupported() || discoverUnsupported) { candidate.setDeviceType(deviceType); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java new file mode 100644 index 000000000..d7854db54 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java @@ -0,0 +1,883 @@ +/* Copyright (C) 2015-2023 Andreas Shimokawa, boun, Carsten Pfeiffer, Daniel + Dakhno, Daniele Gobbetti, JohnnySun, jonnsoft, José Rebelo, Lem Dulfo, Taavi + Eomäe, Uwe Hermann + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities.discovery; + +import static nodomain.freeyourgadget.gadgetbridge.util.GB.toast; + +import android.Manifest; +import android.app.Activity; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanRecord; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.location.LocationManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.ParcelUuid; +import android.os.Parcelable; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Pair; +import android.view.View; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; +import androidx.core.app.ActivityCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.DebugActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.adapter.DeviceCandidateAdapter; +import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconAdapter; +import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconItem; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; +import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; +import nodomain.freeyourgadget.gadgetbridge.util.BondingInterface; +import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + + +public class DiscoveryActivityV2 extends AbstractGBActivity implements AdapterView.OnItemClickListener, + AdapterView.OnItemLongClickListener, + BondingInterface, + GBScanEventProcessor.Callback { + private static final Logger LOG = LoggerFactory.getLogger(DiscoveryActivityV2.class); + + private final Handler handler = new Handler(); + + private static final long SCAN_DURATION = 30000; // 30s + private static final long LIST_REFRESH_THRESHOLD_MS = 2500L; + private long lastListRefresh = System.currentTimeMillis(); + + private final ScanCallback bleScanCallback = new BleScanCallback(); + + private ProgressBar bluetoothProgress; + private ProgressBar bluetoothLEProgress; + + private DeviceCandidateAdapter deviceCandidateAdapter; + private GBDeviceCandidate deviceTarget; + private BluetoothAdapter adapter; + + private Button startButton; + private boolean scanning; + + private long selectedUnsupportedDeviceKey = DebugActivity.SELECT_DEVICE; + + private final Runnable stopRunnable = () -> { + stopDiscovery(); + LOG.info("Discovery stopped by thread timeout."); + }; + + private final BroadcastReceiver bluetoothReceiver = new BluetoothReceiver(); + + private final GBScanEventProcessor deviceFoundProcessor = new GBScanEventProcessor(this); + + // Array to back the adapter for the UI + private final ArrayList deviceCandidates = new ArrayList<>(); + + @RequiresApi(Build.VERSION_CODES.O) + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + BondingUtil.handleActivityResult(this, requestCode, resultCode, data); + } + + @Nullable + private GBDeviceCandidate getCandidateFromMAC(final BluetoothDevice device) { + for (final GBDeviceCandidate candidate : deviceCandidates) { + if (candidate.getMacAddress().equals(device.getAddress())) { + return candidate; + } + } + LOG.warn("This shouldn't happen unless the list somehow emptied itself, device MAC: {}", device.getAddress()); + return null; + } + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + loadSettings(); + + setContentView(R.layout.activity_discovery); + + startButton = findViewById(R.id.discovery_start); + startButton.setOnClickListener(v -> toggleDiscovery()); + + final Button settingsButton = findViewById(R.id.discovery_preferences); + settingsButton.setOnClickListener(v -> { + final Intent enableIntent = new Intent(DiscoveryActivityV2.this, DiscoveryPairingPreferenceActivity.class); + startActivity(enableIntent); + }); + + bluetoothProgress = findViewById(R.id.discovery_progressbar); + bluetoothProgress.setProgress(0); + bluetoothProgress.setIndeterminate(true); + bluetoothProgress.setVisibility(View.GONE); + + bluetoothLEProgress = findViewById(R.id.discovery_ble_progressbar); + bluetoothLEProgress.setProgress(0); + bluetoothLEProgress.setIndeterminate(true); + bluetoothLEProgress.setVisibility(View.GONE); + + deviceCandidateAdapter = new DeviceCandidateAdapter(this, deviceCandidates); + + final ListView deviceCandidatesView = findViewById(R.id.discovery_device_candidates_list); + deviceCandidatesView.setAdapter(deviceCandidateAdapter); + deviceCandidatesView.setOnItemClickListener(this); + deviceCandidatesView.setOnItemLongClickListener(this); + + registerBroadcastReceivers(); + + checkAndRequestLocationPermission(); + + if (!startDiscovery()) { + /* if we couldn't start scanning, go back to the main page. + A toast will have been shown explaining what's wrong */ + finish(); + } + } + + @Override + protected void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelableArrayList("deviceCandidates", deviceCandidates); + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + final List restoredCandidates = savedInstanceState.getParcelableArrayList("deviceCandidates"); + if (restoredCandidates != null) { + deviceCandidates.clear(); + for (final Parcelable p : restoredCandidates) { + final GBDeviceCandidate candidate = (GBDeviceCandidate) p; + deviceCandidates.add(candidate); + } + } + } + + @Override + protected void onDestroy() { + unregisterBroadcastReceivers(); + stopDiscovery(); + super.onDestroy(); + } + + @Override + protected void onStop() { + unregisterBroadcastReceivers(); + stopDiscovery(); + super.onStop(); + } + + @Override + protected void onPause() { + unregisterBroadcastReceivers(); + stopDiscovery(); + super.onPause(); + } + + @Override + protected void onResume() { + loadSettings(); + registerBroadcastReceivers(); + super.onResume(); + } + + private void refreshDeviceList(final boolean throttle) { + handler.post(() -> { + if (throttle && System.currentTimeMillis() - lastListRefresh < LIST_REFRESH_THRESHOLD_MS) { + return; + } + + LOG.debug("Refreshing device list"); + + // Clear and re-populate the list. deviceFoundProcessor keeps insertion order, so newer devices + // will still be at the end + deviceCandidates.clear(); + deviceCandidates.addAll(deviceFoundProcessor.getDevices()); + + deviceCandidateAdapter.notifyDataSetChanged(); + + lastListRefresh = System.currentTimeMillis(); + }); + } + + private void toggleDiscovery() { + if (scanning) { + stopDiscovery(); + } else { + startDiscovery(); + } + } + + private boolean startDiscovery() { + if (scanning) { + LOG.warn("Not starting discovery, because already scanning."); + return false; + } + + LOG.info("Starting discovery"); + startButton.setText(getString(R.string.discovery_stop_scanning)); + + deviceFoundProcessor.clear(); + deviceFoundProcessor.start(); + + refreshDeviceList(false); + + try { + if (!ensureBluetoothReady()) { + toast(DiscoveryActivityV2.this, getString(R.string.discovery_enable_bluetooth), Toast.LENGTH_SHORT, GB.ERROR); + return false; + } + + if (GB.supportsBluetoothLE()) { + startBTLEDiscovery(); + } + startBTDiscovery(); + } catch (final SecurityException e) { + LOG.error("SecurityException on startDiscovery"); + deviceFoundProcessor.stop(); + return false; + } + + setScanning(true); + + return true; + } + + private void stopDiscovery() { + LOG.info("Stopping discovery"); + try { + stopBTDiscovery(); + stopBLEDiscovery(); + } catch (final SecurityException e) { + LOG.error("SecurityException on stopDiscovery"); + } + setScanning(false); + deviceFoundProcessor.stop(); + handler.removeMessages(0, stopRunnable); + + // Refresh the device list one last time when finishing + refreshDeviceList(false); + } + + public void setScanning(final boolean scanning) { + this.scanning = scanning; + if (scanning) { + startButton.setText(getString(R.string.discovery_stop_scanning)); + } else { + startButton.setText(getString(R.string.discovery_start_scanning)); + bluetoothProgress.setVisibility(View.GONE); + bluetoothLEProgress.setVisibility(View.GONE); + } + } + + @RequiresPermission("android.permission.BLUETOOTH_SCAN") + private void startBTLEDiscovery() { + LOG.info("Starting BLE discovery"); + + handler.removeMessages(0, stopRunnable); + handler.sendMessageDelayed(getPostMessage(stopRunnable), SCAN_DURATION); + + // Filters being non-null would be a very good idea with background scan, but in this case, + // not really required. + // TODO getScanFilters maybe + adapter.getBluetoothLeScanner().startScan(null, getScanSettings(), bleScanCallback); + + LOG.debug("Bluetooth LE discovery started successfully"); + bluetoothLEProgress.setVisibility(View.VISIBLE); + } + + @RequiresPermission("android.permission.BLUETOOTH_SCAN") + private void stopBLEDiscovery() { + if (adapter == null) { + return; + } + + final BluetoothLeScanner bluetoothLeScanner = adapter.getBluetoothLeScanner(); + if (bluetoothLeScanner == null) { + LOG.warn("Could not get BluetoothLeScanner()!"); + return; + } + + if (bleScanCallback == null) { + LOG.warn("newLeScanCallback == null!"); + return; + } + + try { + bluetoothLeScanner.stopScan(bleScanCallback); + } catch (final NullPointerException e) { + LOG.warn("Internal NullPointerException when stopping the scan!"); + return; + } + + LOG.debug("Stopped BLE discovery"); + } + + /** + * Starts a regular Bluetooth scan + */ + @RequiresPermission("android.permission.BLUETOOTH_SCAN") + private void startBTDiscovery() { + LOG.info("Starting BT discovery"); + try { + // LineageOS quirk, can't stop scan properly, + // if scan has been started by something else + stopBTDiscovery(); + } catch (final Exception ignored) { + } + handler.removeMessages(0, stopRunnable); + handler.sendMessageDelayed(getPostMessage(stopRunnable), SCAN_DURATION); + + if (adapter.startDiscovery()) { + LOG.debug("Discovery started successfully"); + bluetoothProgress.setVisibility(View.VISIBLE); + } else { + LOG.error("Discovery starting failed"); + } + } + + @RequiresPermission("android.permission.BLUETOOTH_SCAN") + private void stopBTDiscovery() { + if (adapter == null) return; + adapter.cancelDiscovery(); + LOG.info("Stopped BT discovery"); + } + + private void bluetoothStateChanged(final int newState) { + if (newState == BluetoothAdapter.STATE_ON) { + this.adapter = BluetoothAdapter.getDefaultAdapter(); + startButton.setEnabled(true); + } else { + this.adapter = null; + startButton.setEnabled(false); + bluetoothProgress.setVisibility(View.GONE); + bluetoothLEProgress.setVisibility(View.GONE); + } + } + + private boolean checkBluetoothAvailable() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { + LOG.warn("No BLUETOOTH_SCAN permission"); + this.adapter = null; + return false; + } + if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + LOG.warn("No BLUETOOTH_CONNECT permission"); + this.adapter = null; + return false; + } + } + + final BluetoothManager bluetoothService = (BluetoothManager) getSystemService(BLUETOOTH_SERVICE); + if (bluetoothService == null) { + LOG.warn("No bluetooth service available"); + this.adapter = null; + return false; + } + + final BluetoothAdapter adapter = bluetoothService.getAdapter(); + if (adapter == null) { + LOG.warn("No bluetooth adapter available"); + this.adapter = null; + return false; + } + + if (!adapter.isEnabled()) { + LOG.warn("Bluetooth not enabled"); + final Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); + startActivity(enableBtIntent); + this.adapter = null; + return false; + } + + this.adapter = adapter; + return true; + } + + @RequiresPermission("android.permission.BLUETOOTH_SCAN") + private boolean ensureBluetoothReady() { + final boolean available = checkBluetoothAvailable(); + startButton.setEnabled(available); + + if (available) { + adapter.cancelDiscovery(); + // must not return the result of cancelDiscovery() + // appears to return false when currently not scanning + return true; + } + return false; + } + + private static ScanSettings getScanSettings() { + final Prefs prefs = GBApplication.getPrefs(); + final int level = prefs.getInt("scanning_intensity", 1); + + final ScanSettings.Builder builder = new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY); + + LOG.debug("Device discovery - scanning level: {}", level); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + switch (level) { + case 0: + builder.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH); + builder.setMatchMode(ScanSettings.MATCH_MODE_STICKY); + break; + case 1: + builder.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH); + builder.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE); + break; + case 2: + builder.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES); + builder.setMatchMode(ScanSettings.MATCH_MODE_STICKY); + break; + case 3: + builder.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES); + builder.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE); + break; + default: + LOG.warn("Unknown intensity level {}", level); + } + + builder.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED); + } + + return builder.build(); + } + + private List getScanFilters() { + final List allFilters = new ArrayList<>(); + for (final DeviceCoordinator coordinator : DeviceHelper.getInstance().getAllCoordinators()) { + allFilters.addAll(coordinator.createBLEScanFilters()); + } + return allFilters; + } + + private Message getPostMessage(final Runnable runnable) { + final Message message = Message.obtain(handler, runnable); + message.obj = runnable; + return message; + } + + private void checkAndRequestLocationPermission() { + /* This is more or less a copy of what's in ControlCenterv2, but + we do this in case the permissions weren't requested since there + is no way we can scan without this stuff */ + List wantedPermissions = new ArrayList<>(); + if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + LOG.error("No permission to access coarse location!"); + wantedPermissions.add(Manifest.permission.ACCESS_COARSE_LOCATION); + } + if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + LOG.error("No permission to access fine location!"); + wantedPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION); + } + // if we need location permissions, request both together to avoid a bunch of dialogs + if (wantedPermissions.size() > 0) { + toast(DiscoveryActivityV2.this, getString(R.string.error_no_location_access), Toast.LENGTH_SHORT, GB.ERROR); + ActivityCompat.requestPermissions(this, wantedPermissions.toArray(new String[0]), 0); + wantedPermissions.clear(); + } + // Now we have to request background location separately! + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED) { + LOG.error("No permission to access background location!"); + toast(DiscoveryActivityV2.this, getString(R.string.error_no_location_access), Toast.LENGTH_SHORT, GB.ERROR); + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, 0); + } + } + // Now, we can request Bluetooth permissions.... + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { + LOG.error("No permission to access Bluetooth scanning!"); + toast(DiscoveryActivityV2.this, getString(R.string.error_no_bluetooth_scan), Toast.LENGTH_SHORT, GB.ERROR); + wantedPermissions.add(Manifest.permission.BLUETOOTH_SCAN); + } + if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + LOG.error("No permission to access Bluetooth connection!"); + toast(DiscoveryActivityV2.this, getString(R.string.error_no_bluetooth_connect), Toast.LENGTH_SHORT, GB.ERROR); + wantedPermissions.add(Manifest.permission.BLUETOOTH_CONNECT); + } + } + if (wantedPermissions.size() > 0) { + GB.toast(this, getString(R.string.permission_granting_mandatory), Toast.LENGTH_LONG, GB.ERROR); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + ActivityCompat.requestPermissions(this, wantedPermissions.toArray(new String[0]), 0); + } else { + ActivityResultLauncher requestMultiplePermissionsLauncher = + registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), isGranted -> { + if (!isGranted.containsValue(false)) { + // Permission is granted. Continue the action or workflow in your app. + // should we do startDiscovery here?? + } else { + // Explain to the user that the feature is unavailable because the feature requires a permission that the user has denied. + GB.toast(this, getString(R.string.permission_granting_mandatory), Toast.LENGTH_LONG, GB.ERROR); + } + }); + requestMultiplePermissionsLauncher.launch(wantedPermissions.toArray(new String[0])); + } + } + + LocationManager locationManager = (LocationManager) DiscoveryActivityV2.this.getSystemService(Context.LOCATION_SERVICE); + try { + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + // Do nothing + LOG.debug("Some location provider is enabled, assuming location is enabled"); + } else { + toast(DiscoveryActivityV2.this, getString(R.string.require_location_provider), Toast.LENGTH_LONG, GB.ERROR); + DiscoveryActivityV2.this.startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)); + // We can't be sure location was enabled, cancel scan start and wait for new user action + toast(DiscoveryActivityV2.this, getString(R.string.error_location_enabled_mandatory), Toast.LENGTH_SHORT, GB.ERROR); + return; + } + } catch (final Exception ex) { + LOG.error("Exception when checking location status", ex); + } + LOG.info("Permissions seems to be fine for scanning"); + } + + @Override + public void onItemClick(final AdapterView parent, final View view, final int position, final long id) { + final GBDeviceCandidate deviceCandidate = deviceCandidates.get(position); + if (deviceCandidate == null) { + LOG.error("Device candidate clicked, but item not found"); + return; + } + + if (!deviceCandidate.getDeviceType().isSupported()) { + LOG.warn("Unsupported device candidate {}", deviceCandidate); + copyDetailsToClipboard(deviceCandidate); + return; + } + + stopDiscovery(); + + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(deviceCandidate); + LOG.info("Using device candidate {} with coordinator {}", deviceCandidate, coordinator.getClass()); + + if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_REQUIRE_KEY) { + final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceCandidate.getMacAddress()); + + final String authKey = sharedPrefs.getString("authkey", null); + if (authKey == null || authKey.isEmpty()) { + toast(DiscoveryActivityV2.this, getString(R.string.discovery_need_to_enter_authkey), Toast.LENGTH_LONG, GB.WARN); + return; + } else if (authKey.getBytes().length < 34 || !authKey.startsWith("0x")) { + toast(DiscoveryActivityV2.this, getString(R.string.discovery_entered_invalid_authkey), Toast.LENGTH_LONG, GB.WARN); + return; + } + } + + final Class pairingActivity = coordinator.getPairingActivity(); + if (pairingActivity != null) { + final Intent intent = new Intent(this, pairingActivity); + intent.putExtra(DeviceCoordinator.EXTRA_DEVICE_CANDIDATE, deviceCandidate); + startActivity(intent); + } else { + if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_NONE || + coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_LAZY) { + LOG.info("No bonding needed, according to coordinator, so connecting right away"); + BondingUtil.connectThenComplete(this, deviceCandidate); + return; + } + + try { + this.deviceTarget = deviceCandidate; + BondingUtil.initiateCorrectBonding(this, deviceCandidate); + } catch (final Exception e) { + LOG.error("Error pairing device {}", deviceCandidate.getMacAddress(), e); + } + } + } + + private void copyDetailsToClipboard(final GBDeviceCandidate deviceCandidate) { + final List deviceDetails = new ArrayList<>(); + deviceDetails.add(deviceCandidate.getName()); + deviceDetails.add(deviceCandidate.getMacAddress()); + try { + for (final ParcelUuid uuid : deviceCandidate.getServiceUuids()) { + deviceDetails.add(uuid.getUuid().toString()); + } + } catch (final Exception e) { + LOG.error("Error collecting device uuids", e); + } + final String clipboardData = TextUtils.join(", ", deviceDetails); + final ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + final ClipData clip = ClipData.newPlainText(deviceCandidate.getName(), clipboardData); + clipboard.setPrimaryClip(clip); + toast(this, "Device details copied to clipboard", Toast.LENGTH_SHORT, GB.INFO); + } + + @Override + public boolean onItemLongClick(final AdapterView adapterView, final View view, final int position, final long id) { + stopDiscovery(); + + final GBDeviceCandidate deviceCandidate = deviceCandidates.get(position); + if (deviceCandidate == null) { + LOG.error("Device candidate clicked, but item not found"); + return true; + } + + if (!deviceCandidate.getDeviceType().isSupported()) { + showUnsupportedDeviceDialog(deviceCandidate); + return true; + } + + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(deviceCandidate); + final GBDevice device = DeviceHelper.getInstance().toSupportedDevice(deviceCandidate); + if (coordinator.getSupportedDeviceSpecificSettings(device) == null) { + return true; + } + + final Intent startIntent; + startIntent = new Intent(this, DeviceSettingsActivity.class); + startIntent.putExtra(GBDevice.EXTRA_DEVICE, device); + if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_REQUIRE_KEY) { + startIntent.putExtra(DeviceSettingsActivity.MENU_ENTRY_POINT, DeviceSettingsActivity.MENU_ENTRY_POINTS.AUTH_SETTINGS); + } else { + startIntent.putExtra(DeviceSettingsActivity.MENU_ENTRY_POINT, DeviceSettingsActivity.MENU_ENTRY_POINTS.DEVICE_SETTINGS); + } + startActivity(startIntent); + return true; + } + + private void showUnsupportedDeviceDialog(final GBDeviceCandidate deviceCandidate) { + LOG.info("Unsupported device candidate selected: {}", deviceCandidate); + + final Map> allDevices = DebugActivity.getAllSupportedDevices(getApplicationContext()); + + final LinearLayout linearLayout = new LinearLayout(DiscoveryActivityV2.this); + linearLayout.setOrientation(LinearLayout.VERTICAL); + + final ArrayList deviceListArray = new ArrayList<>(); + for (Map.Entry> item : allDevices.entrySet()) { + deviceListArray.add(new SpinnerWithIconItem(item.getKey(), item.getValue().first, item.getValue().second)); + } + final SpinnerWithIconAdapter deviceListAdapter = new SpinnerWithIconAdapter( + DiscoveryActivityV2.this, + R.layout.spinner_with_image_layout, + R.id.spinner_item_text, + deviceListArray + ); + + final Spinner deviceListSpinner = new Spinner(DiscoveryActivityV2.this); + deviceListSpinner.setAdapter(deviceListAdapter); + deviceListSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, final int pos, final long id) { + final SpinnerWithIconItem selectedItem = (SpinnerWithIconItem) parent.getItemAtPosition(pos); + selectedUnsupportedDeviceKey = selectedItem.getId(); + } + + @Override + public void onNothingSelected(final AdapterView arg0) { + } + }); + linearLayout.addView(deviceListSpinner); + + final LinearLayout macLayout = new LinearLayout(DiscoveryActivityV2.this); + macLayout.setOrientation(LinearLayout.HORIZONTAL); + macLayout.setPadding(20, 0, 20, 0); + linearLayout.addView(macLayout); + + new MaterialAlertDialogBuilder(DiscoveryActivityV2.this) + .setCancelable(true) + .setTitle(R.string.add_test_device) + .setView(linearLayout) + .setPositiveButton(R.string.ok, (dialog, which) -> { + if (selectedUnsupportedDeviceKey != DebugActivity.SELECT_DEVICE) { + DebugActivity.createTestDevice(DiscoveryActivityV2.this, selectedUnsupportedDeviceKey, deviceCandidate.getMacAddress()); + finish(); + } + }) + .setNegativeButton(R.string.Cancel, (dialog, which) -> { + }) + .show(); + } + + @Override + public void onBondingComplete(final boolean success) { + finish(); + } + + @Override + public GBDeviceCandidate getCurrentTarget() { + return this.deviceTarget; + } + + @Override + public void registerBroadcastReceivers() { + final IntentFilter bluetoothIntents = new IntentFilter(); + bluetoothIntents.addAction(BluetoothDevice.ACTION_FOUND); + bluetoothIntents.addAction(BluetoothDevice.ACTION_UUID); + bluetoothIntents.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED); + bluetoothIntents.addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED); + bluetoothIntents.addAction(BluetoothAdapter.ACTION_STATE_CHANGED); + + registerReceiver(bluetoothReceiver, bluetoothIntents); + } + + @Override + public void unregisterBroadcastReceivers() { + AndroidUtils.safeUnregisterBroadcastReceiver(this, bluetoothReceiver); + } + + @Override + public Context getContext() { + return this; + } + + private void loadSettings() { + final Prefs prefs = GBApplication.getPrefs(); + deviceFoundProcessor.setIgnoreBonded(prefs.getBoolean("ignore_bonded_devices", true)); + deviceFoundProcessor.setDiscoverUnsupported(prefs.getBoolean("discover_unsupported_devices", false)); + } + + @Override + public void onDeviceChanged() { + refreshDeviceList(true); + } + + private final class BluetoothReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + switch (Objects.requireNonNull(intent.getAction())) { + case BluetoothAdapter.ACTION_DISCOVERY_STARTED: { + LOG.debug("ACTION_DISCOVERY_STARTED"); + break; + } + case BluetoothAdapter.ACTION_STATE_CHANGED: { + LOG.debug("ACTION_STATE_CHANGED "); + bluetoothStateChanged(intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF)); + break; + } + case BluetoothDevice.ACTION_FOUND: { + final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if (device == null) { + LOG.warn("ACTION_FOUND with null device"); + return; + } + LOG.debug("ACTION_FOUND {}", device.getAddress()); + final short rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, GBDevice.RSSI_UNKNOWN); + deviceFoundProcessor.scheduleProcessing(new GBScanEvent(device, rssi, null)); + break; + } + case BluetoothDevice.ACTION_UUID: { + final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if (device == null) { + LOG.warn("ACTION_UUID with null device"); + return; + } + LOG.debug("ACTION_UUID {}", device.getAddress()); + final short rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, GBDevice.RSSI_UNKNOWN); + final Parcelable[] uuids = intent.getParcelableArrayExtra(BluetoothDevice.EXTRA_UUID); + final ParcelUuid[] uuids2 = AndroidUtils.toParcelUuids(uuids); + deviceFoundProcessor.scheduleProcessing(new GBScanEvent(device, rssi, uuids2)); + break; + } + case BluetoothDevice.ACTION_BOND_STATE_CHANGED: { + LOG.debug("ACTION_BOND_STATE_CHANGED"); + final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if (device != null) { + final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE); + LOG.debug("Bond state: {}", bondState); + + if (bondState == BluetoothDevice.BOND_BONDED) { + BondingUtil.handleDeviceBonded((BondingInterface) context, getCandidateFromMAC(device)); + } + } + break; + } + } + } + } + + private final class BleScanCallback extends ScanCallback { + @Override + public void onScanResult(final int callbackType, final ScanResult result) { + super.onScanResult(callbackType, result); + try { + final ScanRecord scanRecord = result.getScanRecord(); + ParcelUuid[] uuids = null; + if (scanRecord != null) { + final List serviceUuids = scanRecord.getServiceUuids(); + if (serviceUuids != null) { + uuids = serviceUuids.toArray(new ParcelUuid[0]); + } + } + final BluetoothDevice device = result.getDevice(); + final short rssi = (short) result.getRssi(); + LOG.debug("BLE result: {}, {}, {}", device.getAddress(), ((scanRecord != null) ? scanRecord.getBytes().length : -1), rssi); + deviceFoundProcessor.scheduleProcessing(new GBScanEvent(device, rssi, uuids)); + } catch (final Exception e) { + LOG.warn("Error handling BLE scan result", e); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java new file mode 100644 index 000000000..a2a79c8b6 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java @@ -0,0 +1,53 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities.discovery; + +import android.bluetooth.BluetoothDevice; +import android.os.ParcelUuid; + +import androidx.annotation.Nullable; + +/** + * A scan event originating from either BT or BLE scan. References the BluetoothDevice, rssi, + * and service UUIDs, if any. + */ +public class GBScanEvent { + private final BluetoothDevice device; + private final short rssi; + + @Nullable + private final ParcelUuid[] serviceUuids; + + public GBScanEvent(final BluetoothDevice device, final short rssi, @Nullable final ParcelUuid[] serviceUuids) { + this.device = device; + this.rssi = rssi; + this.serviceUuids = serviceUuids; + } + + public BluetoothDevice getDevice() { + return device; + } + + public short getRssi() { + return rssi; + } + + @Nullable + public ParcelUuid[] getServiceUuids() { + return serviceUuids; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java new file mode 100644 index 000000000..53db69da3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java @@ -0,0 +1,284 @@ +/* Copyright (C) 2015-2023 Andreas Shimokawa, boun, Carsten Pfeiffer, Daniel + Dakhno, Daniele Gobbetti, JohnnySun, jonnsoft, José Rebelo, Lem Dulfo, Taavi + Eomäe, Uwe Hermann + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities.discovery; + +import android.os.ParcelUuid; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; + +/** + * A dedicated thread to process {@link GBScanEvent}s. This class keeps a map from mac address to + * GBDeviceCandidate, with the current known state of each candidate. + *

+ * Processing works as follows: + * - The processor consumes mac addresses from the eventsToProcessQueue + * - Mac addresses are placed on the queue when there are one or more new GBScanEvents to process in + * the eventsToProcessMap map + * - The eventsToProcessMap contains a list of events per device, so that they can be processed in batch + * - The GBDeviceEvent for the corresponding mac address in candidatesByAddress gets updated with the new + * information, and matched against the coordinators. + */ +public final class GBScanEventProcessor implements Runnable { + private static final Logger LOG = LoggerFactory.getLogger(GBScanEventProcessor.class); + + private static final ParcelUuid ZERO_UUID = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"); + + // Devices that can be ignored by just the address (eg. already bonded) + private final Set devicesToIgnore = new HashSet<>(); + private final Map candidatesByAddress = new LinkedHashMap<>(); + + private final BlockingQueue eventsToProcessQueue = new LinkedBlockingQueue<>(); + private final Map> eventsToProcessMap = new HashMap<>(); + + private boolean ignoreBonded = true; + private boolean discoverUnsupported = false; + + private volatile boolean running = false; + private Thread thread = null; + + private final Callback callback; + + public GBScanEventProcessor(final Callback callback) { + this.callback = callback; + } + + @Override + public void run() { + LOG.info("Device Found Processor Thread started."); + + while (running) { + try { + LOG.debug("Polling found devices queue, current size = {}", eventsToProcessQueue.size()); + final String candidateAddress = eventsToProcessQueue.take(); + if (candidateAddress != null) { + if (processAllScanEvents(candidateAddress)) { + callback.onDeviceChanged(); + } + } + } catch (final InterruptedException e) { + LOG.warn("Processing thread interrupted"); + Thread.currentThread().interrupt(); + break; + } + } + } + + public void start() { + if (running) { + LOG.warn("Already running!"); + return; + } + + running = true; + thread = new Thread("Gadgetbridge Device Found Processor Thread") { + @Override + public void run() { + GBScanEventProcessor.this.run(); + } + }; + thread.start(); + } + + public void stop() { + running = false; + + if (thread != null) { + thread.interrupt(); + thread = null; + } + } + + public void clear() { + devicesToIgnore.clear(); + candidatesByAddress.clear(); + eventsToProcessMap.clear(); + eventsToProcessQueue.clear(); + } + + public void setIgnoreBonded(boolean ignoreBonded) { + this.ignoreBonded = ignoreBonded; + } + + public void setDiscoverUnsupported(boolean discoverUnsupported) { + this.discoverUnsupported = discoverUnsupported; + } + + /** + * Returns the current list of GBDeviceCandidates. The candidates are cloned, since they can be + * modified concurrently by the processor. + */ + public List getDevices() { + final List ret = new ArrayList<>(); + // candidatesByAddress keeps insertion order, so newer devices will be at the end + synchronized (candidatesByAddress) { + for (final Map.Entry entry : candidatesByAddress.entrySet()) { + ret.add(entry.getValue().clone()); + } + } + return ret; + } + + /** + * Schedule a {@link GBScanEvent} to be processed asynchronously. + */ + public void scheduleProcessing(final GBScanEvent event) { + LOG.debug("Scheduling {} for processing ({})", event.getDevice().getAddress(), event.getServiceUuids()); + + final String address = event.getDevice().getAddress(); + synchronized (eventsToProcessMap) { + if (!eventsToProcessMap.containsKey(address)) { + eventsToProcessMap.put(address, new LinkedList<>()); + } + Objects.requireNonNull(eventsToProcessMap.get(address)).add(event); + } + + try { + eventsToProcessQueue.put(address); + } catch (final InterruptedException e) { + LOG.error("Failed to put device on processing queue", e); + } + } + + private boolean processCandidate(final GBDeviceCandidate candidate) { + LOG.debug("found device: {}, {}", candidate.getName(), candidate.getMacAddress()); + if (LOG.isDebugEnabled()) { + final ParcelUuid[] uuids = candidate.getServiceUuids(); + if (uuids != null && uuids.length > 0) { + for (ParcelUuid uuid : uuids) { + LOG.debug(" supports uuid: " + uuid.toString()); + } + } + } + + final DeviceType deviceType = DeviceHelper.getInstance().getSupportedType(candidate); + + if (deviceType.isSupported() || discoverUnsupported) { + candidate.setDeviceType(deviceType); + synchronized (candidatesByAddress) { + candidatesByAddress.put(candidate.getMacAddress(), candidate); + } + } + + return deviceType.isSupported(); + } + + private boolean processAllScanEvents(final String address) { + final List events; + synchronized (eventsToProcessMap) { + events = eventsToProcessMap.remove(address); + } + if (events == null || events.isEmpty()) { + LOG.warn("Attempted to process {}, but found no events", address); + return false; + } + + if (devicesToIgnore.contains(address)) { + LOG.trace("Ignoring {} events for {}", events.size(), address); + return false; + } + + LOG.debug("Processing {} events for {}", events.size(), address); + + GBDeviceCandidate candidate = candidatesByAddress.get(address); + + String previousName = null; + ParcelUuid[] previousUuids = null; + + if (candidate == null) { + // First time we see this device + LOG.debug("Found {} for the first time", address); + final GBScanEvent firstEvent = events.get(0); + events.remove(0); + candidate = new GBDeviceCandidate(firstEvent.getDevice(), firstEvent.getRssi(), firstEvent.getServiceUuids()); + } else { + previousName = candidate.getName(); + previousUuids = candidate.getServiceUuids(); + } + + if (candidate.isBonded() && ignoreBonded) { + LOG.trace("Ignoring already bonded device {}", address); + devicesToIgnore.add(address); + return false; + } + + // Update the device with the remaining events + for (final GBScanEvent event : events) { + candidate.setRssi(event.getRssi()); + candidate.addUuids(event.getServiceUuids()); + } + + candidate.refreshNameIfUnknown(); + try { + candidate.addUuids(candidate.getDevice().getUuids()); + } catch (final SecurityException e) { + LOG.error("SecurityException on candidate.getDevice().getUuids()"); + } + + if (Objects.equals(candidate.getName(), previousName) && Arrays.equals(candidate.getServiceUuids(), previousUuids)) { + // Neither name nor uuids changed, do not reprocess + LOG.trace("Not reprocessing {} due to no changes", address); + return false; + } + + if (candidate.isNameKnown()) { + if (processCandidate(candidate)) { + LOG.info( + "Device {} ({}) is supported as '{}' without scanning services", + candidate.getDevice(), + candidate.getName(), + candidate.getDeviceType() + ); + return true; + } + } + + if (candidate.getServiceUuids().length == 0 || (candidate.getServiceUuids().length == 1 && candidate.getServiceUuids()[0].equals(ZERO_UUID))) { + LOG.debug("Fetching uuids for {} with sdp", candidate.getDevice().getAddress()); + try { + candidate.getDevice().fetchUuidsWithSdp(); + } catch (final SecurityException e) { + LOG.error("SecurityException on candidate.getDevice().fetchUuidsWithSdp()"); + } + } + + return true; + } + + public interface Callback { + void onDeviceChanged(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java index a7ae9ef3f..79a605ba3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java @@ -68,7 +68,7 @@ public class DeviceCandidateAdapter extends ArrayAdapter { deviceImageView.setImageResource(device.getDeviceType().getIcon()); final List statusLines = new ArrayList<>(); - if (device.getDevice().getBondState() == BluetoothDevice.BOND_BONDED) { + if (device.isBonded()) { statusLines.add(getContext().getString(R.string.device_is_currently_bonded)); if (!GBApplication.getPrefs().getBoolean("ignore_bonded_devices", true)) { // This could be passed to the constructor instead deviceImageView.setImageResource(device.getDeviceType().getDisabledIcon()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java index 61d356ba7..a695371ea 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java @@ -33,10 +33,12 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; @@ -65,7 +67,11 @@ public class LenovoWatchPairingActivity extends AbstractGBActivity implements Bo } if (deviceCandidate == null) { Toast.makeText(this, getString(R.string.message_cannot_pair_no_mac), Toast.LENGTH_SHORT).show(); - startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + if (GBApplication.useNewDiscoveryActivity()) { + startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } else { + startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } finish(); return; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java index b60c92b02..0eefbb92e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java @@ -42,6 +42,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.AboutUserPreferencesActiv import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; @@ -76,7 +77,11 @@ public class MiBandPairingActivity extends AbstractGBActivity implements Bonding if (deviceCandidate == null) { Toast.makeText(this, getString(R.string.message_cannot_pair_no_mac), Toast.LENGTH_SHORT).show(); - startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + if (GBApplication.useNewDiscoveryActivity()) { + startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } else { + startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } finish(); return; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java index e273f49f4..94b366202 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java @@ -43,6 +43,7 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; @@ -172,7 +173,11 @@ public class PebblePairingActivity extends AbstractGBActivity implements Bonding if (success) { startActivity(new Intent(this, ControlCenterv2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); } else { - startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + if (GBApplication.useNewDiscoveryActivity()) { + startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } else { + startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } } // If it's not a LE Pebble, initiate a connection when bonding is complete diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java index 3fb99fb08..ab7ebd93f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java @@ -31,10 +31,12 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; @@ -63,7 +65,11 @@ public class Watch9PairingActivity extends AbstractGBActivity implements Bonding if (deviceCandidate == null) { Toast.makeText(this, getString(R.string.message_cannot_pair_no_mac), Toast.LENGTH_SHORT).show(); - startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + if (GBApplication.useNewDiscoveryActivity()) { + startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } else { + startActivity(new Intent(this, DiscoveryActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + } finish(); return; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java index 354b61e64..e9a044364 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java @@ -28,7 +28,7 @@ import org.slf4j.LoggerFactory; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; @@ -43,19 +43,22 @@ import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; * Gadgetbridge. Only if a DeviceCoordinator steps up and confirms to * support this candidate, will the candidate be promoted to a GBDevice. */ -public class GBDeviceCandidate implements Parcelable { +public class GBDeviceCandidate implements Parcelable, Cloneable { private static final Logger LOG = LoggerFactory.getLogger(GBDeviceCandidate.class); - private final BluetoothDevice device; - private final short rssi; - private final ParcelUuid[] serviceUuids; + private BluetoothDevice device; + private short rssi; + private ParcelUuid[] serviceUuids; private DeviceType deviceType = DeviceType.UNKNOWN; + + // Cached values for device name and bond status, to avoid querying the remote bt device private String deviceName; + private Boolean isBonded = null; public GBDeviceCandidate(BluetoothDevice device, short rssi, ParcelUuid[] serviceUuids) { this.device = device; this.rssi = rssi; - this.serviceUuids = mergeServiceUuids(serviceUuids, device.getUuids()); + this.serviceUuids = serviceUuids; } private GBDeviceCandidate(Parcel in) { @@ -66,8 +69,13 @@ public class GBDeviceCandidate implements Parcelable { rssi = (short) in.readInt(); deviceType = DeviceType.valueOf(in.readString()); - ParcelUuid[] uuids = AndroidUtils.toParcelUuids(in.readParcelableArray(getClass().getClassLoader())); - serviceUuids = mergeServiceUuids(uuids, device.getUuids()); + serviceUuids = AndroidUtils.toParcelUuids(in.readParcelableArray(getClass().getClassLoader())); + + deviceName = in.readString(); + final int isBondedInt = in.readInt(); + if (isBondedInt != -1) { + isBonded = (isBondedInt == 1); + } } @Override @@ -76,6 +84,12 @@ public class GBDeviceCandidate implements Parcelable { dest.writeInt(rssi); dest.writeString(deviceType.name()); dest.writeParcelableArray(serviceUuids, 0); + dest.writeString(deviceName); + if (isBonded == null) { + dest.writeInt(-1); + } else { + dest.writeInt(isBonded ? 1 : 0); + } } public static final Creator CREATOR = new Creator() { @@ -107,7 +121,7 @@ public class GBDeviceCandidate implements Parcelable { } private ParcelUuid[] mergeServiceUuids(ParcelUuid[] serviceUuids, ParcelUuid[] deviceUuids) { - Set uuids = new HashSet<>(); + Set uuids = new LinkedHashSet<>(); if (serviceUuids != null) { uuids.addAll(Arrays.asList(serviceUuids)); } @@ -117,6 +131,30 @@ public class GBDeviceCandidate implements Parcelable { return uuids.toArray(new ParcelUuid[0]); } + public void addUuids(ParcelUuid[] newUuids) { + this.serviceUuids = mergeServiceUuids(serviceUuids, newUuids); + } + + public void setRssi(short rssi) { + this.rssi = rssi; + } + + public boolean isBonded() { + if (isBonded == null) { + try { + isBonded = device.getBondState() == BluetoothDevice.BOND_BONDED; + } catch (final SecurityException e) { + /* This should never happen because we need all the permissions + to get to the point where we can even scan, but 'SecurityException' check + is added to stop Android Studio errors */ + LOG.error("SecurityException on getBonded"); + isBonded = false; + } + } + + return isBonded; + } + @NonNull public ParcelUuid[] getServiceUuids() { return serviceUuids; @@ -138,24 +176,37 @@ public class GBDeviceCandidate implements Parcelable { } public String getName() { - if (this.deviceName != null ) { - return this.deviceName; + if (isNameKnown()) { + return deviceName; } + return "(unknown)"; + } + + public void refreshNameIfUnknown() { + if (isNameKnown()) { + return; + } + try { - Method method = device.getClass().getMethod("getAliasName"); - if (method != null) { - deviceName = (String) method.invoke(device); + final Method method = device.getClass().getMethod("getAliasName"); + deviceName = (String) method.invoke(device); + } catch (final NoSuchMethodException ignore) { + // ignored + } catch (final IllegalAccessException | InvocationTargetException ignore) { + LOG.warn("Could not get device alias for {}", device.getAddress()); + } + if (deviceName == null || deviceName.isEmpty()) { + try { + deviceName = device.getName(); + } catch (final SecurityException e) { + // Should never happen + LOG.error("SecurityException on device.getName"); } - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ignore) { - LOG.info("Could not get device alias for " + device.getName()); } - if (deviceName == null || deviceName.length() == 0) { - deviceName = device.getName(); - } - if (deviceName == null || deviceName.length() == 0) { - deviceName = "(unknown)"; - } - return deviceName; + } + + public boolean isNameKnown() { + return deviceName != null && !deviceName.isEmpty(); } public short getRssi() { @@ -189,4 +240,21 @@ public class GBDeviceCandidate implements Parcelable { public String toString() { return getName() + ": " + getMacAddress() + " (" + getDeviceType() + ")"; } + + @NonNull + @Override + public GBDeviceCandidate clone() { + try { + final GBDeviceCandidate clone = (GBDeviceCandidate) super.clone(); + clone.device = this.device; + clone.rssi = this.rssi; + clone.serviceUuids = this.serviceUuids; + clone.deviceType = this.deviceType; + clone.deviceName = this.deviceName; + clone.isBonded = this.isBonded; + return clone; + } catch (final CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java index f16375720..d7863207e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java @@ -242,6 +242,7 @@ public class DeviceHelper { public GBDevice toSupportedDevice(BluetoothDevice device) { GBDeviceCandidate candidate = new GBDeviceCandidate(device, GBDevice.RSSI_UNKNOWN, device.getUuids()); + candidate.refreshNameIfUnknown(); return toSupportedDevice(candidate); } diff --git a/app/src/main/res/layout/activity_discovery.xml b/app/src/main/res/layout/activity_discovery.xml index 086c0da6a..100c4fb96 100644 --- a/app/src/main/res/layout/activity_discovery.xml +++ b/app/src/main/res/layout/activity_discovery.xml @@ -6,7 +6,7 @@ android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" - tools:context="nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivity"> + tools:context="nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2"> Enabling this option will ignore devices that have been bonded/paired already when scanning Discover unsupported devices Enabling this option will display all discovered bluetooth devices when scanning. Short tap will copy device name and mac address to clipboard. Long press will launch `Add test device` dialog. Can cause potential app freezing issues. + Enable new discover activity + Enable the new discover activity, which should fix device discovery issues. Disable this if you face any issues while searching for or pairing with your device. Location must be turned on to scan for devices Sony SWR12 Settings Low vibration enabled diff --git a/app/src/main/res/xml/discovery_pairing_preferences.xml b/app/src/main/res/xml/discovery_pairing_preferences.xml index ce04d93e8..58beafd77 100644 --- a/app/src/main/res/xml/discovery_pairing_preferences.xml +++ b/app/src/main/res/xml/discovery_pairing_preferences.xml @@ -23,6 +23,13 @@ android:summary="@string/discover_unsupported_devices_description" android:title="@string/discover_unsupported_devices" app:iconSpaceReserved="false" /> +