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