1 /* 2 * Copyright (C) 2017 Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 * use this file except in compliance with the License. You may obtain a copy of 6 * the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 * License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 17 package com.google.android.mobly.snippet.bundled.bluetooth; 18 19 import android.bluetooth.BluetoothAdapter; 20 import android.bluetooth.BluetoothDevice; 21 import android.content.BroadcastReceiver; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.IntentFilter; 25 import android.content.pm.PackageManager; 26 import android.os.Build; 27 import android.os.Bundle; 28 import androidx.test.platform.app.InstrumentationRegistry; 29 import androidx.test.uiautomator.By; 30 import androidx.test.uiautomator.BySelector; 31 import androidx.test.uiautomator.UiDevice; 32 import androidx.test.uiautomator.Until; 33 import com.google.android.mobly.snippet.Snippet; 34 import com.google.android.mobly.snippet.bundled.utils.JsonSerializer; 35 import com.google.android.mobly.snippet.bundled.utils.Utils; 36 import com.google.android.mobly.snippet.rpc.Rpc; 37 import com.google.android.mobly.snippet.util.Log; 38 import java.util.ArrayList; 39 import java.util.Collections; 40 import java.util.List; 41 import java.util.HashMap; 42 import java.util.Map; 43 import java.util.NoSuchElementException; 44 import java.util.concurrent.ConcurrentHashMap; 45 import java.util.regex.Pattern; 46 import org.json.JSONException; 47 48 /** Snippet class exposing Android APIs in BluetoothAdapter. */ 49 public class BluetoothAdapterSnippet implements Snippet { 50 51 private static class BluetoothAdapterSnippetException extends Exception { 52 53 private static final long serialVersionUID = 1; 54 BluetoothAdapterSnippetException(String msg)55 public BluetoothAdapterSnippetException(String msg) { 56 super(msg); 57 } 58 BluetoothAdapterSnippetException(String msg, Throwable err)59 public BluetoothAdapterSnippetException(String msg, Throwable err) { 60 super(msg, err); 61 } 62 } 63 64 // Timeout to measure consistent BT state. 65 private static final int BT_MATCHING_STATE_INTERVAL_SEC = 5; 66 // Default timeout in seconds. 67 private static final int TIMEOUT_TOGGLE_STATE_SEC = 30; 68 private final Context mContext; 69 private final PackageManager mPackageManager; 70 private static final BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); 71 private final JsonSerializer mJsonSerializer = new JsonSerializer(); 72 private static final ConcurrentHashMap<String, BluetoothDevice> mDiscoveryResults = 73 new ConcurrentHashMap<>(); 74 private volatile boolean mIsDiscoveryFinished = false; 75 private final Map<String, BroadcastReceiver> mReceivers; 76 BluetoothAdapterSnippet()77 public BluetoothAdapterSnippet() throws Throwable { 78 mContext = InstrumentationRegistry.getInstrumentation().getContext(); 79 // Use a synchronized map to avoid racing problems 80 mReceivers = Collections.synchronizedMap(new HashMap<String, BroadcastReceiver>()); 81 Utils.adaptShellPermissionIfRequired(mContext); 82 mPackageManager = mContext.getPackageManager(); 83 } 84 85 /** 86 * Gets a {@link BluetoothDevice} that has either been paired or discovered. 87 * 88 * @param deviceAddress 89 * @return 90 */ getKnownDeviceByAddress(String deviceAddress)91 public static BluetoothDevice getKnownDeviceByAddress(String deviceAddress) { 92 BluetoothDevice pairedDevice = getPairedDeviceByAddress(deviceAddress); 93 if (pairedDevice != null) { 94 return pairedDevice; 95 } 96 BluetoothDevice discoveredDevice = mDiscoveryResults.get(deviceAddress); 97 if (discoveredDevice != null) { 98 return discoveredDevice; 99 } 100 throw new NoSuchElementException( 101 "No device with address " 102 + deviceAddress 103 + " is paired or has been discovered. Cannot proceed."); 104 } 105 getPairedDeviceByAddress(String deviceAddress)106 private static BluetoothDevice getPairedDeviceByAddress(String deviceAddress) { 107 for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) { 108 if (device.getAddress().equalsIgnoreCase(deviceAddress)) { 109 return device; 110 } 111 } 112 return null; 113 } 114 115 /* Gets the UiDevice instance for UI operations. */ getUiDevice()116 private static UiDevice getUiDevice() throws BluetoothAdapterSnippetException { 117 try { 118 return UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); 119 } catch (IllegalStateException e) { 120 throw new BluetoothAdapterSnippetException("Failed to get UiDevice. Please ensure that " 121 + "no other UiAutomation service is running.", e); 122 } 123 } 124 125 @Rpc(description = "Enable bluetooth with a 30s timeout.") btEnable()126 public void btEnable() throws BluetoothAdapterSnippetException, InterruptedException { 127 if (mBluetoothAdapter.getState() == BluetoothAdapter.STATE_ON) { 128 return; 129 } 130 waitForStableBtState(); 131 132 if (Build.VERSION.SDK_INT >= 33) { 133 // BluetoothAdapter#enable is removed from public SDK for 33 and above, so uses an 134 // intent instead. 135 UiDevice uiDevice = getUiDevice(); 136 Intent enableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); 137 enableIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 138 // Triggers the system UI popup to ask for explicit permission. 139 mContext.startActivity(enableIntent); 140 // Clicks the "ALLOW" button. 141 BySelector allowButtonSelector = By.text(TEXT_PATTERN_ALLOW).clickable(true); 142 uiDevice.wait(Until.findObject(allowButtonSelector), 10); 143 uiDevice.findObject(allowButtonSelector).click(); 144 } else if (!mBluetoothAdapter.enable()) { 145 throw new BluetoothAdapterSnippetException("Failed to start enabling bluetooth."); 146 } 147 if (!Utils.waitUntil( 148 () -> mBluetoothAdapter.getState() == BluetoothAdapter.STATE_ON, 149 TIMEOUT_TOGGLE_STATE_SEC)) { 150 throw new BluetoothAdapterSnippetException( 151 String.format( 152 "Bluetooth did not turn on within %ss.", TIMEOUT_TOGGLE_STATE_SEC)); 153 } 154 } 155 156 @Rpc(description = "Disable bluetooth with a 30s timeout.") btDisable()157 public void btDisable() throws BluetoothAdapterSnippetException, InterruptedException { 158 if (mBluetoothAdapter.getState() == BluetoothAdapter.STATE_OFF) { 159 return; 160 } 161 waitForStableBtState(); 162 if (!mBluetoothAdapter.disable()) { 163 throw new BluetoothAdapterSnippetException("Failed to start disabling bluetooth."); 164 } 165 if (!Utils.waitUntil( 166 () -> mBluetoothAdapter.getState() == BluetoothAdapter.STATE_OFF, 167 TIMEOUT_TOGGLE_STATE_SEC)) { 168 throw new BluetoothAdapterSnippetException( 169 String.format( 170 "Bluetooth did not turn off within %ss.", TIMEOUT_TOGGLE_STATE_SEC)); 171 } 172 } 173 174 @Rpc(description = "Return true if Bluetooth is enabled, false otherwise.") btIsEnabled()175 public boolean btIsEnabled() { 176 return mBluetoothAdapter.isEnabled(); 177 } 178 179 @Rpc( 180 description = 181 "Get bluetooth discovery results, which is a list of serialized BluetoothDevice objects.") btGetCachedScanResults()182 public ArrayList<Bundle> btGetCachedScanResults() { 183 return mJsonSerializer.serializeBluetoothDeviceList(mDiscoveryResults.values()); 184 } 185 186 @Rpc(description = "Set the friendly Bluetooth name of the local Bluetooth adapter.") btSetName(String name)187 public void btSetName(String name) throws BluetoothAdapterSnippetException { 188 if (!btIsEnabled()) { 189 throw new BluetoothAdapterSnippetException( 190 "Bluetooth is not enabled, cannot set Bluetooth name."); 191 } 192 if (!mBluetoothAdapter.setName(name)) { 193 throw new BluetoothAdapterSnippetException( 194 "Failed to set local Bluetooth name to " + name); 195 } 196 } 197 198 @Rpc(description = "Get the friendly Bluetooth name of the local Bluetooth adapter.") btGetName()199 public String btGetName() { 200 return mBluetoothAdapter.getName(); 201 } 202 203 @Rpc(description = "Automatically confirm the incoming BT pairing request.") btStartAutoAcceptIncomingPairRequest()204 public void btStartAutoAcceptIncomingPairRequest() throws Throwable { 205 BroadcastReceiver receiver = new PairingBroadcastReceiver(mContext); 206 mContext.registerReceiver( 207 receiver, PairingBroadcastReceiver.filter); 208 mReceivers.put("AutoAcceptIncomingPairReceiver", receiver); 209 } 210 211 @Rpc(description = "Stop the incoming BT pairing request.") btStopAutoAcceptIncomingPairRequest()212 public void btStopAutoAcceptIncomingPairRequest() throws Throwable { 213 BroadcastReceiver receiver = mReceivers.remove("AutoAcceptIncomingPairReceiver"); 214 mContext.unregisterReceiver(receiver); 215 } 216 217 @Rpc(description = "Returns the hardware address of the local Bluetooth adapter.") btGetAddress()218 public String btGetAddress() { 219 return mBluetoothAdapter.getAddress(); 220 } 221 222 @Rpc( 223 description = 224 "Start discovery, wait for discovery to complete, and return results, which is a list of " 225 + "serialized BluetoothDevice objects.") btDiscoverAndGetResults()226 public List<Bundle> btDiscoverAndGetResults() 227 throws InterruptedException, BluetoothAdapterSnippetException { 228 IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND); 229 filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); 230 if (mBluetoothAdapter.isDiscovering()) { 231 mBluetoothAdapter.cancelDiscovery(); 232 } 233 mDiscoveryResults.clear(); 234 mIsDiscoveryFinished = false; 235 BroadcastReceiver receiver = new BluetoothScanReceiver(); 236 mContext.registerReceiver(receiver, filter); 237 try { 238 if (!mBluetoothAdapter.startDiscovery()) { 239 throw new BluetoothAdapterSnippetException( 240 "Failed to initiate Bluetooth Discovery."); 241 } 242 if (!Utils.waitUntil(() -> mIsDiscoveryFinished, 120)) { 243 throw new BluetoothAdapterSnippetException( 244 "Failed to get discovery results after 2 mins, timeout!"); 245 } 246 } finally { 247 mContext.unregisterReceiver(receiver); 248 } 249 return btGetCachedScanResults(); 250 } 251 252 @Rpc(description = "Become discoverable in Bluetooth.") btBecomeDiscoverable(Integer duration)253 public void btBecomeDiscoverable(Integer duration) throws Throwable { 254 if (!btIsEnabled()) { 255 throw new BluetoothAdapterSnippetException( 256 "Bluetooth is not enabled, cannot become discoverable."); 257 } 258 if (Build.VERSION.SDK_INT >= 31) { 259 // BluetoothAdapter#setScanMode is removed from public SDK for 31 and above, so uses an 260 // intent instead. 261 UiDevice uiDevice = getUiDevice(); 262 Intent discoverableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); 263 discoverableIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 264 discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, duration); 265 // Triggers the system UI popup to ask for explicit permission. 266 mContext.startActivity(discoverableIntent); 267 268 if (mPackageManager.hasSystemFeature(PackageManager.FEATURE_WATCH)) { 269 // Clicks the "OK" button. 270 BySelector okButtonSelector = By.desc(TEXT_PATTERN_OK).clickable(true); 271 uiDevice.wait(Until.findObject(okButtonSelector), 10); 272 uiDevice.findObject(okButtonSelector).click(); 273 } else { 274 // Clicks the "ALLOW" button. 275 BySelector allowButtonSelector = By.text(TEXT_PATTERN_ALLOW).clickable(true); 276 uiDevice.wait(Until.findObject(allowButtonSelector), 10); 277 uiDevice.findObject(allowButtonSelector).click(); 278 } 279 } else if (Build.VERSION.SDK_INT >= 30) { 280 if (!(boolean) 281 Utils.invokeByReflection( 282 mBluetoothAdapter, 283 "setScanMode", 284 BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE, 285 (long) duration * 1000)) { 286 throw new BluetoothAdapterSnippetException("Failed to become discoverable."); 287 } else { 288 if (!(boolean) 289 Utils.invokeByReflection( 290 mBluetoothAdapter, 291 "setScanMode", 292 BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE, 293 duration)) { 294 throw new BluetoothAdapterSnippetException("Failed to become discoverable."); 295 } 296 } 297 } 298 } 299 300 private static final Pattern TEXT_PATTERN_ALLOW = 301 Pattern.compile("allow", Pattern.CASE_INSENSITIVE); 302 private static final Pattern TEXT_PATTERN_OK = 303 Pattern.compile("ok", Pattern.CASE_INSENSITIVE); 304 305 @Rpc(description = "Cancel ongoing bluetooth discovery.") btCancelDiscovery()306 public void btCancelDiscovery() throws BluetoothAdapterSnippetException { 307 if (!mBluetoothAdapter.isDiscovering()) { 308 Log.d("No ongoing bluetooth discovery."); 309 return; 310 } 311 IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); 312 mIsDiscoveryFinished = false; 313 BroadcastReceiver receiver = new BluetoothScanReceiver(); 314 mContext.registerReceiver(receiver, filter); 315 try { 316 if (!mBluetoothAdapter.cancelDiscovery()) { 317 throw new BluetoothAdapterSnippetException( 318 "Failed to initiate to cancel bluetooth discovery."); 319 } 320 if (!Utils.waitUntil(() -> mIsDiscoveryFinished, 120)) { 321 throw new BluetoothAdapterSnippetException( 322 "Failed to get discovery results after 2 mins, timeout!"); 323 } 324 } finally { 325 mContext.unregisterReceiver(receiver); 326 } 327 } 328 329 @Rpc(description = "Stop being discoverable in Bluetooth.") btStopBeingDiscoverable()330 public void btStopBeingDiscoverable() throws Throwable { 331 if (!(boolean) 332 Utils.invokeByReflection( 333 mBluetoothAdapter, 334 "setScanMode", 335 BluetoothAdapter.SCAN_MODE_NONE, 336 0 /* duration is not used for this */)) { 337 throw new BluetoothAdapterSnippetException("Failed to stop being discoverable."); 338 } 339 } 340 341 @Rpc(description = "Get the list of paired bluetooth devices.") btGetPairedDevices()342 public List<Bundle> btGetPairedDevices() 343 throws BluetoothAdapterSnippetException, InterruptedException, JSONException { 344 ArrayList<Bundle> pairedDevices = new ArrayList<>(); 345 for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) { 346 pairedDevices.add(JsonSerializer.serializeBluetoothDevice(device)); 347 } 348 return pairedDevices; 349 } 350 351 @Rpc(description = "Pair with a bluetooth device.") btPairDevice(String deviceAddress)352 public void btPairDevice(String deviceAddress) throws Throwable { 353 BluetoothDevice device = mDiscoveryResults.get(deviceAddress); 354 if (device == null) { 355 throw new NoSuchElementException( 356 "No device with address " 357 + deviceAddress 358 + " has been discovered. Cannot proceed."); 359 } 360 mContext.registerReceiver( 361 new PairingBroadcastReceiver(mContext), PairingBroadcastReceiver.filter); 362 if (!(boolean) Utils.invokeByReflection(device, "createBond")) { 363 throw new BluetoothAdapterSnippetException( 364 "Failed to initiate the pairing process to device: " + deviceAddress); 365 } 366 if (!Utils.waitUntil(() -> device.getBondState() == BluetoothDevice.BOND_BONDED, 120)) { 367 throw new BluetoothAdapterSnippetException( 368 "Failed to pair with device " + deviceAddress + " after 2min."); 369 } 370 } 371 372 @Rpc(description = "Un-pair a bluetooth device.") btUnpairDevice(String deviceAddress)373 public void btUnpairDevice(String deviceAddress) throws Throwable { 374 for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) { 375 if (device.getAddress().equalsIgnoreCase(deviceAddress)) { 376 if (!(boolean) Utils.invokeByReflection(device, "removeBond")) { 377 throw new BluetoothAdapterSnippetException( 378 "Failed to initiate the un-pairing process for device: " 379 + deviceAddress); 380 } 381 if (!Utils.waitUntil( 382 () -> device.getBondState() == BluetoothDevice.BOND_NONE, 30)) { 383 throw new BluetoothAdapterSnippetException( 384 "Failed to un-pair device " + deviceAddress + " after 30s."); 385 } 386 return; 387 } 388 } 389 throw new NoSuchElementException("No device with address " + deviceAddress + " is paired."); 390 } 391 392 @Override shutdown()393 public void shutdown() { 394 for (Map.Entry<String, BroadcastReceiver> entry : mReceivers.entrySet()) { 395 mContext.unregisterReceiver(entry.getValue()); 396 } 397 mReceivers.clear(); 398 } 399 400 private class BluetoothScanReceiver extends BroadcastReceiver { 401 402 /** 403 * The receiver gets an ACTION_FOUND intent whenever a new device is found. 404 * ACTION_DISCOVERY_FINISHED intent is received when the discovery process ends. 405 */ 406 @Override onReceive(Context context, Intent intent)407 public void onReceive(Context context, Intent intent) { 408 String action = intent.getAction(); 409 if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) { 410 mIsDiscoveryFinished = true; 411 } else if (BluetoothDevice.ACTION_FOUND.equals(action)) { 412 BluetoothDevice device = 413 (BluetoothDevice) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); 414 mDiscoveryResults.put(device.getAddress(), device); 415 } 416 } 417 } 418 419 /** 420 * Waits until the bluetooth adapter state has stabilized. We consider BT state stabilized if it 421 * hasn't changed within 5 sec. 422 */ waitForStableBtState()423 private static void waitForStableBtState() throws BluetoothAdapterSnippetException { 424 long timeoutMs = System.currentTimeMillis() + TIMEOUT_TOGGLE_STATE_SEC * 1000; 425 long continuousStateIntervalMs = 426 System.currentTimeMillis() + BT_MATCHING_STATE_INTERVAL_SEC * 1000; 427 int prevState = mBluetoothAdapter.getState(); 428 while (System.currentTimeMillis() < timeoutMs) { 429 // Delay. 430 Utils.waitUntil(() -> false, /* timeout= */ 1); 431 432 int currentState = mBluetoothAdapter.getState(); 433 if (currentState != prevState) { 434 continuousStateIntervalMs = 435 System.currentTimeMillis() + BT_MATCHING_STATE_INTERVAL_SEC * 1000; 436 } 437 if (continuousStateIntervalMs <= System.currentTimeMillis()) { 438 return; 439 } 440 prevState = currentState; 441 } 442 throw new BluetoothAdapterSnippetException( 443 String.format( 444 "Failed to reach a stable Bluetooth state within %d s", 445 TIMEOUT_TOGGLE_STATE_SEC)); 446 } 447 } 448