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