• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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