aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/com/google
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/com/google')
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/AccountSnippet.java337
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/AudioSnippet.java134
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeAdvertiserSnippet.java178
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeScannerSnippet.java138
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/FileSnippet.java67
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/LogSnippet.java64
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/MediaSnippet.java66
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/NetworkingSnippet.java151
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/NotificationSnippet.java40
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/SmsSnippet.java219
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/StorageSnippet.java37
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/TelephonySnippet.java70
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/WifiManagerSnippet.java434
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/BluetoothAdapterSnippet.java361
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/PairingBroadcastReceiver.java30
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothA2dpSnippet.java118
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothHearingAidSnippet.java116
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonDeserializer.java104
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonSerializer.java205
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/MbsEnums.java92
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/RpcEnum.java89
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/Utils.java213
22 files changed, 3263 insertions, 0 deletions
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/AccountSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/AccountSnippet.java
new file mode 100644
index 0000000..3c21dbf
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/AccountSnippet.java
@@ -0,0 +1,337 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerFuture;
+import android.accounts.AccountsException;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.SyncAdapterType;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.RequiresApi;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.util.Log;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+/**
+ * Snippet class exposing Android APIs related to management of device accounts.
+ *
+ * <p>Android devices can have accounts of any type added and synced. New types can be created by
+ * apps by implementing a {@link android.content.ContentProvider} for a particular account type.
+ *
+ * <p>Google (gmail) accounts are of type "com.google" and their handling is managed by the
+ * operating system. This class allows you to add and remove Google accounts from a device.
+ */
+public class AccountSnippet implements Snippet {
+ private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
+ private static final String AUTH_TOKEN_TYPE = "mail";
+
+ private static class AccountSnippetException extends Exception {
+ private static final long serialVersionUID = 1;
+
+ public AccountSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ private final AccountManager mAccountManager;
+ private final List<Object> mSyncStatusObserverHandles;
+
+ private final Map<String, Set<String>> mSyncAllowList;
+ private final ReentrantReadWriteLock mLock;
+
+ public AccountSnippet() {
+ Context context = InstrumentationRegistry.getInstrumentation().getContext();
+ mAccountManager = AccountManager.get(context);
+ mSyncStatusObserverHandles = new LinkedList<>();
+ mSyncAllowList = new HashMap<>();
+ mLock = new ReentrantReadWriteLock();
+ }
+
+ /**
+ * Adds a Google account to the device.
+ *
+ * @param username Username of the account to add (including @gmail.com).
+ * @param password Password of the account to add.
+ */
+ @Rpc(
+ description =
+ "Add a Google (GMail) account to the device, with account data sync disabled.")
+ public void addAccount(String username, String password)
+ throws AccountSnippetException, AccountsException, IOException {
+ // Check for existing account. If we try to re-add an existing account, Android throws an
+ // exception that says "Account does not exist or not visible. Maybe change pwd?" which is
+ // a little hard to understand.
+ if (listAccounts().contains(username)) {
+ throw new AccountSnippetException(
+ "Account " + username + " already exists on the device");
+ }
+ Bundle addAccountOptions = new Bundle();
+ addAccountOptions.putString("username", username);
+ addAccountOptions.putString("password", password);
+ AccountManagerFuture<Bundle> future =
+ mAccountManager.addAccount(
+ GOOGLE_ACCOUNT_TYPE,
+ AUTH_TOKEN_TYPE,
+ null /* requiredFeatures */,
+ addAccountOptions,
+ null /* activity */,
+ null /* authCallback */,
+ null /* handler */);
+ Bundle result = future.getResult();
+ if (result.containsKey(AccountManager.KEY_ERROR_CODE)) {
+ throw new AccountSnippetException(
+ String.format(
+ Locale.US,
+ "Failed to add account due to code %d: %s",
+ result.getInt(AccountManager.KEY_ERROR_CODE),
+ result.getString(AccountManager.KEY_ERROR_MESSAGE)));
+ }
+
+ // Disable sync to avoid test flakiness as accounts fetch additional data.
+ // It takes a while for all sync adapters to be populated, so register for broadcasts when
+ // sync is starting and disable them there.
+ // NOTE: this listener is NOT unregistered because several sync requests for the new account
+ // will come in over time.
+ Account account = new Account(username, GOOGLE_ACCOUNT_TYPE);
+ Object handle =
+ ContentResolver.addStatusChangeListener(
+ ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
+ | ContentResolver.SYNC_OBSERVER_TYPE_PENDING,
+ which -> {
+ for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) {
+ // Ignore non-Google account types.
+ if (!adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE)) {
+ continue;
+ }
+ // If a content provider is not allowListed, then disable it.
+ // Because startSync and stopSync synchronously update the allowList
+ // and sync settings, writelock both the allowList check and the
+ // call to sync together.
+ mLock.writeLock().lock();
+ try {
+ if (!isAdapterAllowListed(username, adapter.authority)) {
+ updateSync(account, adapter.authority, false /* sync */);
+ }
+ } finally {
+ mLock.writeLock().unlock();
+ }
+ }
+ });
+ mSyncStatusObserverHandles.add(handle);
+ }
+
+ /**
+ * Removes an account from the device.
+ *
+ * <p>The account has to be Google account.
+ *
+ * @param username the username of the account to remove.
+ * @throws AccountSnippetException if removing the account failed.
+ */
+ @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1)
+ @Rpc(description = "Remove a Google account.")
+ public void removeAccount(String username) throws AccountSnippetException {
+ if (!mAccountManager.removeAccountExplicitly(getAccountByName(username))) {
+ throw new AccountSnippetException("Failed to remove account '" + username + "'.");
+ }
+ }
+
+ /**
+ * Get an existing account by its username.
+ *
+ * <p>Google account only.
+ *
+ * @param username the username of the account to remove.
+ * @return tHe account with the username.
+ * @throws AccountSnippetException if no account has the given username.
+ */
+ private Account getAccountByName(String username) throws AccountSnippetException {
+ Account[] accounts = mAccountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE);
+ for (Account account : accounts) {
+ if (account.name.equals(username)) {
+ return account;
+ }
+ }
+ throw new AccountSnippetException(
+ "Account '" + username + "' does not exist on the device.");
+ }
+
+ /**
+ * Checks to see if the SyncAdapter is allowListed.
+ *
+ * <p>AccountSnippet disables syncing by default when adding an account, except for allowListed
+ * SyncAdapters. This function checks the allowList for a specific account-authority pair.
+ *
+ * @param username Username of the account (including @gmail.com).
+ * @param authority The authority of a content provider that should be checked.
+ */
+ private boolean isAdapterAllowListed(String username, String authority) {
+ boolean result = false;
+ mLock.readLock().lock();
+ try {
+ Set<String> allowListedProviders = mSyncAllowList.get(username);
+ if (allowListedProviders != null) {
+ result = allowListedProviders.contains(authority);
+ }
+ } finally {
+ mLock.readLock().unlock();
+ }
+ return result;
+ }
+
+ /**
+ * Updates ContentResolver sync settings for an Account's specified SyncAdapter.
+ *
+ * <p>Sets an accounts SyncAdapter (selected based on authority) to sync/not-sync automatically
+ * and immediately requests/cancels a sync.
+ *
+ * <p>updateSync should always be called under {@link AccountSnippet#mLock} write lock to avoid
+ * flapping between the getSyncAutomatically and setSyncAutomatically calls.
+ *
+ * @param account A Google Account.
+ * @param authority The authority of a content provider that should (not) be synced.
+ * @param sync Whether or not the account's content provider should be synced.
+ */
+ private void updateSync(Account account, String authority, boolean sync) {
+ if (ContentResolver.getSyncAutomatically(account, authority) != sync) {
+ ContentResolver.setSyncAutomatically(account, authority, sync);
+ if (sync) {
+ ContentResolver.requestSync(account, authority, new Bundle());
+ } else {
+ ContentResolver.cancelSync(account, authority);
+ }
+ Log.i(
+ "Set sync to "
+ + sync
+ + " for account "
+ + account
+ + ", adapter "
+ + authority
+ + ".");
+ }
+ }
+
+ /**
+ * Enables syncing of a SyncAdapter for a given content provider.
+ *
+ * <p>Adds the authority to a allowList, and immediately requests a sync.
+ *
+ * @param username Username of the account (including @gmail.com).
+ * @param authority The authority of a content provider that should be synced.
+ */
+ @Rpc(description = "Enables syncing of a SyncAdapter for a content provider.")
+ public void startSync(String username, String authority) throws AccountSnippetException {
+ if (!listAccounts().contains(username)) {
+ throw new AccountSnippetException("Account " + username + " is not on the device");
+ }
+ // Add to the allowList
+ mLock.writeLock().lock();
+ try {
+ if (mSyncAllowList.containsKey(username)) {
+ mSyncAllowList.get(username).add(authority);
+ } else {
+ mSyncAllowList.put(username, new HashSet<String>(Arrays.asList(authority)));
+ }
+ // Update the Sync settings
+ for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) {
+ // Find the Google account content provider.
+ if (adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE)
+ && adapter.authority.equals(authority)) {
+ Account account = new Account(username, GOOGLE_ACCOUNT_TYPE);
+ updateSync(account, authority, true);
+ }
+ }
+ } finally {
+ mLock.writeLock().unlock();
+ }
+ }
+
+ /**
+ * Disables syncing of a SyncAdapter for a given content provider.
+ *
+ * <p>Removes the content provider authority from a allowList.
+ *
+ * @param username Username of the account (including @gmail.com).
+ * @param authority The authority of a content provider that should not be synced.
+ */
+ @Rpc(description = "Disables syncing of a SyncAdapter for a content provider.")
+ public void stopSync(String username, String authority) throws AccountSnippetException {
+ if (!listAccounts().contains(username)) {
+ throw new AccountSnippetException("Account " + username + " is not on the device");
+ }
+ // Remove from allowList
+ mLock.writeLock().lock();
+ try {
+ if (mSyncAllowList.containsKey(username)) {
+ Set<String> allowListedProviders = mSyncAllowList.get(username);
+ allowListedProviders.remove(authority);
+ if (allowListedProviders.isEmpty()) {
+ mSyncAllowList.remove(username);
+ }
+ }
+ // Update the Sync settings
+ for (SyncAdapterType adapter : ContentResolver.getSyncAdapterTypes()) {
+ // Find the Google account content provider.
+ if (adapter.accountType.equals(GOOGLE_ACCOUNT_TYPE)
+ && adapter.authority.equals(authority)) {
+ Account account = new Account(username, GOOGLE_ACCOUNT_TYPE);
+ updateSync(account, authority, false);
+ }
+ }
+ } finally {
+ mLock.writeLock().unlock();
+ }
+ }
+
+ /**
+ * Returns a list of all Google accounts on the device.
+ *
+ * <p>TODO(adorokhine): Support accounts of other types with an optional 'type' kwarg.
+ */
+ @Rpc(description = "List all Google (GMail) accounts on the device.")
+ public Set<String> listAccounts() throws SecurityException {
+ Account[] accounts = mAccountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE);
+ Set<String> usernames = new TreeSet<>();
+ for (Account account : accounts) {
+ usernames.add(account.name);
+ }
+ return usernames;
+ }
+
+ @Override
+ public void shutdown() {
+ for (Object handle : mSyncStatusObserverHandles) {
+ ContentResolver.removeStatusChangeListener(handle);
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/AudioSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/AudioSnippet.java
new file mode 100644
index 0000000..9b4874f
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/AudioSnippet.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.content.Context;
+import android.media.AudioManager;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import java.lang.reflect.Method;
+
+/* Snippet class to control audio */
+public class AudioSnippet implements Snippet {
+
+ private final AudioManager mAudioManager;
+
+ public AudioSnippet() {
+ Context context = InstrumentationRegistry.getInstrumentation().getContext();
+ mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ }
+
+ @Rpc(description = "Sets the microphone mute state: True = Muted, False = not muted.")
+ public void setMicrophoneMute(boolean state) {
+ mAudioManager.setMicrophoneMute(state);
+ }
+
+ @Rpc(description = "Returns whether or not the microphone is muted.")
+ public boolean isMicrophoneMute() {
+ return mAudioManager.isMicrophoneMute();
+ }
+
+ @Rpc(description = "Returns whether or not any music is active.")
+ public boolean isMusicActive() {
+ return mAudioManager.isMusicActive();
+ }
+
+ @Rpc(description = "Gets the music stream volume.")
+ public Integer getMusicVolume() {
+ return mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+ }
+
+ @Rpc(description = "Gets the maximum music stream volume value.")
+ public int getMusicMaxVolume() {
+ return mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
+ }
+
+ @Rpc(
+ description =
+ "Sets the music stream volume. The minimum value is 0. Use 'getMusicMaxVolume'"
+ + " to determine the maximum.")
+ public void setMusicVolume(Integer value) {
+ mAudioManager.setStreamVolume(
+ AudioManager.STREAM_MUSIC, value, 0 /* flags, 0 = no flags */);
+ }
+
+ @Rpc(description = "Gets the ringer volume.")
+ public Integer getRingVolume() {
+ return mAudioManager.getStreamVolume(AudioManager.STREAM_RING);
+ }
+
+ @Rpc(description = "Gets the maximum ringer volume value.")
+ public int getRingMaxVolume() {
+ return mAudioManager.getStreamMaxVolume(AudioManager.STREAM_RING);
+ }
+
+ @Rpc(
+ description =
+ "Sets the ringer stream volume. The minimum value is 0. Use 'getRingMaxVolume'"
+ + " to determine the maximum.")
+ public void setRingVolume(Integer value) {
+ mAudioManager.setStreamVolume(AudioManager.STREAM_RING, value, 0 /* flags, 0 = no flags */);
+ }
+
+ @Rpc(description = "Gets the voice call volume.")
+ public Integer getVoiceCallVolume() {
+ return mAudioManager.getStreamVolume(AudioManager.STREAM_VOICE_CALL);
+ }
+
+ @Rpc(description = "Gets the maximum voice call volume value.")
+ public int getVoiceCallMaxVolume() {
+ return mAudioManager.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL);
+ }
+
+ @Rpc(
+ description =
+ "Sets the voice call stream volume. The minimum value is 0. Use"
+ + " 'getVoiceCallMaxVolume' to determine the maximum.")
+ public void setVoiceCallVolume(Integer value) {
+ mAudioManager.setStreamVolume(
+ AudioManager.STREAM_VOICE_CALL, value, 0 /* flags, 0 = no flags */);
+ }
+
+ @Rpc(description = "Silences all audio streams.")
+ public void muteAll() throws Exception {
+ /* Get numStreams from AudioSystem through reflection. If for some reason this fails,
+ * calling muteAll will throw. */
+ Class<?> audioSystem = Class.forName("android.media.AudioSystem");
+ Method getNumStreamTypes = audioSystem.getDeclaredMethod("getNumStreamTypes");
+ int numStreams = (int) getNumStreamTypes.invoke(null /* instance */);
+ for (int i = 0; i < numStreams; i++) {
+ mAudioManager.setStreamVolume(i /* audio stream */, 0 /* value */, 0 /* flags */);
+ }
+ }
+
+ @Rpc(
+ description =
+ "Puts the ringer volume at the lowest setting, but does not set it to "
+ + "DO NOT DISTURB; the phone will vibrate when receiving a call.")
+ public void muteRing() {
+ setRingVolume(0);
+ }
+
+ @Rpc(description = "Mute music stream.")
+ public void muteMusic() {
+ setMusicVolume(0);
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeAdvertiserSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeAdvertiserSnippet.java
new file mode 100644
index 0000000..e161a5b
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeAdvertiserSnippet.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.ParcelUuid;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.utils.JsonDeserializer;
+import com.google.android.mobly.snippet.bundled.utils.JsonSerializer;
+import com.google.android.mobly.snippet.bundled.utils.RpcEnum;
+import com.google.android.mobly.snippet.event.EventCache;
+import com.google.android.mobly.snippet.event.SnippetEvent;
+import com.google.android.mobly.snippet.rpc.AsyncRpc;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.rpc.RpcMinSdk;
+import com.google.android.mobly.snippet.util.Log;
+import java.util.HashMap;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** Snippet class exposing Android APIs in WifiManager. */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1)
+public class BluetoothLeAdvertiserSnippet implements Snippet {
+ private static class BluetoothLeAdvertiserSnippetException extends Exception {
+ private static final long serialVersionUID = 1;
+
+ public BluetoothLeAdvertiserSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ private final BluetoothLeAdvertiser mAdvertiser;
+ private static final EventCache sEventCache = EventCache.getInstance();
+
+ private final HashMap<String, AdvertiseCallback> mAdvertiseCallbacks = new HashMap<>();
+
+ public BluetoothLeAdvertiserSnippet() {
+ mAdvertiser = BluetoothAdapter.getDefaultAdapter().getBluetoothLeAdvertiser();
+ }
+
+ /**
+ * Start Bluetooth LE advertising.
+ *
+ * <p>This can be called multiple times, and each call is associated with a {@link
+ * AdvertiseCallback} object, which is used to stop the advertising.
+ *
+ * @param callbackId
+ * @param advertiseSettings A JSONObject representing a {@link AdvertiseSettings object}. E.g.
+ * <pre>
+ * {
+ * "AdvertiseMode": "ADVERTISE_MODE_BALANCED",
+ * "Timeout": (int, milliseconds),
+ * "Connectable": (bool),
+ * "TxPowerLevel": "ADVERTISE_TX_POWER_LOW"
+ * }
+ * </pre>
+ *
+ * @param advertiseData A JSONObject representing a {@link AdvertiseData} object. E.g.
+ * <pre>
+ * {
+ * "IncludeDeviceName": (bool),
+ * # JSON list, each element representing a set of service data, which is composed of
+ * # a UUID, and an optional string.
+ * "ServiceData": [
+ * {
+ * "UUID": (A string representation of {@link ParcelUuid}),
+ * "Data": (Optional, The string representation of what you want to
+ * advertise, base64 encoded)
+ * # If you want to add a UUID without data, simply omit the "Data"
+ * # field.
+ * }
+ * ]
+ * }
+ * </pre>
+ *
+ * @throws BluetoothLeAdvertiserSnippetException
+ * @throws JSONException
+ */
+ @RpcMinSdk(Build.VERSION_CODES.LOLLIPOP_MR1)
+ @AsyncRpc(description = "Start BLE advertising.")
+ public void bleStartAdvertising(
+ String callbackId, JSONObject advertiseSettings, JSONObject advertiseData)
+ throws BluetoothLeAdvertiserSnippetException, JSONException {
+ if (!BluetoothAdapter.getDefaultAdapter().isEnabled()) {
+ throw new BluetoothLeAdvertiserSnippetException(
+ "Bluetooth is disabled, cannot start BLE advertising.");
+ }
+ AdvertiseSettings settings = JsonDeserializer.jsonToBleAdvertiseSettings(advertiseSettings);
+ AdvertiseData data = JsonDeserializer.jsonToBleAdvertiseData(advertiseData);
+ AdvertiseCallback advertiseCallback = new DefaultAdvertiseCallback(callbackId);
+ mAdvertiser.startAdvertising(settings, data, advertiseCallback);
+ mAdvertiseCallbacks.put(callbackId, advertiseCallback);
+ }
+
+ /**
+ * Stop a BLE advertising.
+ *
+ * @param callbackId The callbackId corresponding to the {@link
+ * BluetoothLeAdvertiserSnippet#bleStartAdvertising} call that started the advertising.
+ * @throws BluetoothLeScannerSnippet.BluetoothLeScanSnippetException
+ */
+ @RpcMinSdk(Build.VERSION_CODES.LOLLIPOP_MR1)
+ @Rpc(description = "Stop BLE advertising.")
+ public void bleStopAdvertising(String callbackId) throws BluetoothLeAdvertiserSnippetException {
+ AdvertiseCallback callback = mAdvertiseCallbacks.remove(callbackId);
+ if (callback == null) {
+ throw new BluetoothLeAdvertiserSnippetException(
+ "No advertising session found for ID " + callbackId);
+ }
+ mAdvertiser.stopAdvertising(callback);
+ }
+
+ private static class DefaultAdvertiseCallback extends AdvertiseCallback {
+ private final String mCallbackId;
+ public static RpcEnum ADVERTISE_FAILURE_ERROR_CODE =
+ new RpcEnum.Builder()
+ .add("ADVERTISE_FAILED_ALREADY_STARTED", ADVERTISE_FAILED_ALREADY_STARTED)
+ .add("ADVERTISE_FAILED_DATA_TOO_LARGE", ADVERTISE_FAILED_DATA_TOO_LARGE)
+ .add(
+ "ADVERTISE_FAILED_FEATURE_UNSUPPORTED",
+ ADVERTISE_FAILED_FEATURE_UNSUPPORTED)
+ .add("ADVERTISE_FAILED_INTERNAL_ERROR", ADVERTISE_FAILED_INTERNAL_ERROR)
+ .add(
+ "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS",
+ ADVERTISE_FAILED_TOO_MANY_ADVERTISERS)
+ .build();
+
+ public DefaultAdvertiseCallback(String callbackId) {
+ mCallbackId = callbackId;
+ }
+
+ public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+ Log.e("Bluetooth LE advertising started with settings: " + settingsInEffect.toString());
+ SnippetEvent event = new SnippetEvent(mCallbackId, "onStartSuccess");
+ Bundle advertiseSettings =
+ JsonSerializer.serializeBleAdvertisingSettings(settingsInEffect);
+ event.getData().putBundle("SettingsInEffect", advertiseSettings);
+ sEventCache.postEvent(event);
+ }
+
+ public void onStartFailure(int errorCode) {
+ Log.e("Bluetooth LE advertising failed to start with error code: " + errorCode);
+ SnippetEvent event = new SnippetEvent(mCallbackId, "onStartFailure");
+ final String errorCodeString = ADVERTISE_FAILURE_ERROR_CODE.getString(errorCode);
+ event.getData().putString("ErrorCode", errorCodeString);
+ sEventCache.postEvent(event);
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ for (AdvertiseCallback callback : mAdvertiseCallbacks.values()) {
+ mAdvertiser.stopAdvertising(callback);
+ }
+ mAdvertiseCallbacks.clear();
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeScannerSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeScannerSnippet.java
new file mode 100644
index 0000000..7e133d1
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeScannerSnippet.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanResult;
+import android.os.Build;
+import android.os.Bundle;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.utils.JsonSerializer;
+import com.google.android.mobly.snippet.bundled.utils.MbsEnums;
+import com.google.android.mobly.snippet.event.EventCache;
+import com.google.android.mobly.snippet.event.SnippetEvent;
+import com.google.android.mobly.snippet.rpc.AsyncRpc;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.rpc.RpcMinSdk;
+import com.google.android.mobly.snippet.util.Log;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+/** Snippet class exposing Android APIs in WifiManager. */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1)
+public class BluetoothLeScannerSnippet implements Snippet {
+ private static class BluetoothLeScanSnippetException extends Exception {
+ private static final long serialVersionUID = 1;
+
+ public BluetoothLeScanSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ private final BluetoothLeScanner mScanner;
+ private final EventCache mEventCache = EventCache.getInstance();
+ private final HashMap<String, ScanCallback> mScanCallbacks = new HashMap<>();
+ private final JsonSerializer mJsonSerializer = new JsonSerializer();
+
+ public BluetoothLeScannerSnippet() {
+ mScanner = BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner();
+ }
+
+ /**
+ * Start a BLE scan.
+ *
+ * @param callbackId
+ * @throws BluetoothLeScanSnippetException
+ */
+ @RpcMinSdk(Build.VERSION_CODES.LOLLIPOP_MR1)
+ @AsyncRpc(description = "Start BLE scan.")
+ public void bleStartScan(String callbackId) throws BluetoothLeScanSnippetException {
+ if (!BluetoothAdapter.getDefaultAdapter().isEnabled()) {
+ throw new BluetoothLeScanSnippetException(
+ "Bluetooth is disabled, cannot start BLE scan.");
+ }
+ DefaultScanCallback callback = new DefaultScanCallback(callbackId);
+ mScanner.startScan(callback);
+ mScanCallbacks.put(callbackId, callback);
+ }
+
+ /**
+ * Stop a BLE scan.
+ *
+ * @param callbackId The callbackId corresponding to the {@link
+ * BluetoothLeScannerSnippet#bleStartScan} call that started the scan.
+ * @throws BluetoothLeScanSnippetException
+ */
+ @RpcMinSdk(Build.VERSION_CODES.LOLLIPOP_MR1)
+ @Rpc(description = "Stop a BLE scan.")
+ public void bleStopScan(String callbackId) throws BluetoothLeScanSnippetException {
+ ScanCallback callback = mScanCallbacks.remove(callbackId);
+ if (callback == null) {
+ throw new BluetoothLeScanSnippetException("No ongoing scan with ID: " + callbackId);
+ }
+ mScanner.stopScan(callback);
+ }
+
+ @Override
+ public void shutdown() {
+ for (ScanCallback callback : mScanCallbacks.values()) {
+ mScanner.stopScan(callback);
+ }
+ mScanCallbacks.clear();
+ }
+
+ private class DefaultScanCallback extends ScanCallback {
+ private final String mCallbackId;
+
+ public DefaultScanCallback(String callbackId) {
+ mCallbackId = callbackId;
+ }
+
+ public void onScanResult(int callbackType, ScanResult result) {
+ Log.i("Got Bluetooth LE scan result.");
+ SnippetEvent event = new SnippetEvent(mCallbackId, "onScanResult");
+ String callbackTypeString =
+ MbsEnums.BLE_SCAN_RESULT_CALLBACK_TYPE.getString(callbackType);
+ event.getData().putString("CallbackType", callbackTypeString);
+ event.getData().putBundle("result", mJsonSerializer.serializeBleScanResult(result));
+ mEventCache.postEvent(event);
+ }
+
+ public void onBatchScanResults(List<ScanResult> results) {
+ Log.i("Got Bluetooth LE batch scan results.");
+ SnippetEvent event = new SnippetEvent(mCallbackId, "onBatchScanResult");
+ ArrayList<Bundle> resultList = new ArrayList<>(results.size());
+ for (ScanResult result : results) {
+ resultList.add(mJsonSerializer.serializeBleScanResult(result));
+ }
+ event.getData().putParcelableArrayList("results", resultList);
+ mEventCache.postEvent(event);
+ }
+
+ public void onScanFailed(int errorCode) {
+ Log.e("Bluetooth LE scan failed with error code: " + errorCode);
+ SnippetEvent event = new SnippetEvent(mCallbackId, "onScanFailed");
+ String errorCodeString = MbsEnums.BLE_SCAN_FAILED_ERROR_CODE.getString(errorCode);
+ event.getData().putString("ErrorCode", errorCodeString);
+ mEventCache.postEvent(event);
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/FileSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/FileSnippet.java
new file mode 100644
index 0000000..b6d6ca5
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/FileSnippet.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import java.io.IOException;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/** Snippet class for File and abstract storage URI operation RPCs. */
+public class FileSnippet implements Snippet {
+
+ private final Context mContext;
+
+ public FileSnippet() {
+ mContext = InstrumentationRegistry.getInstrumentation().getContext();
+ }
+
+ @Rpc(description = "Compute MD5 hash on a content URI. Return the MD5 has has a hex string.")
+ public String fileMd5Hash(String uri) throws IOException, NoSuchAlgorithmException {
+ Uri uri_ = Uri.parse(uri);
+ ParcelFileDescriptor pfd = mContext.getContentResolver().openFileDescriptor(uri_, "r");
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ int length = (int) pfd.getStatSize();
+ byte[] buf = new byte[length];
+ ParcelFileDescriptor.AutoCloseInputStream stream =
+ new ParcelFileDescriptor.AutoCloseInputStream(pfd);
+ DigestInputStream dis = new DigestInputStream(stream, md);
+ try {
+ dis.read(buf, 0, length);
+ return Utils.bytesToHexString(md.digest());
+ } finally {
+ dis.close();
+ stream.close();
+ }
+ }
+
+ @Rpc(description = "Remove a file pointed to by the content URI.")
+ public void fileDeleteContent(String uri) {
+ Uri uri_ = Uri.parse(uri);
+ mContext.getContentResolver().delete(uri_, null, null);
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/LogSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/LogSnippet.java
new file mode 100644
index 0000000..9f889e4
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/LogSnippet.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.util.Log;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.rpc.Rpc;
+
+/** Snippet class exposing Android APIs related to logging. */
+public class LogSnippet implements Snippet {
+ private String mTag = "MoblyTestLog";
+
+ @Rpc(description = "Set the tag to use for logX Rpcs. Default is 'MoblyTestLog'.")
+ public void logSetTag(String tag) {
+ mTag = tag;
+ }
+
+ @Rpc(description = "Log at info level.")
+ public void logI(String message) {
+ Log.i(mTag, message);
+ }
+
+ @Rpc(description = "Log at debug level.")
+ public void logD(String message) {
+ Log.d(mTag, message);
+ }
+
+ @Rpc(description = "Log at error level.")
+ public void logE(String message) {
+ Log.e(mTag, message);
+ }
+
+ @Rpc(description = "Log at warning level.")
+ public void logW(String message) {
+ Log.w(mTag, message);
+ }
+
+ @Rpc(description = "Log at verbose level.")
+ public void logV(String message) {
+ Log.v(mTag, message);
+ }
+
+ @Rpc(description = "Log at WTF level.")
+ public void logWtf(String message) {
+ Log.wtf(mTag, message);
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/MediaSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/MediaSnippet.java
new file mode 100644
index 0000000..58b38ac
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/MediaSnippet.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.media.AudioAttributes;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import java.io.IOException;
+
+/* Snippet class to control media playback. */
+public class MediaSnippet implements Snippet {
+
+ private final MediaPlayer mPlayer;
+
+ public MediaSnippet() {
+ mPlayer = new MediaPlayer();
+ }
+
+ @Rpc(description = "Resets snippet media player to an idle state, regardless of current state.")
+ public void mediaReset() {
+ mPlayer.reset();
+ }
+
+ @Rpc(description = "Play an audio file stored at a specified file path in external storage.")
+ public void mediaPlayAudioFile(String mediaFilePath) throws IOException {
+ mediaReset();
+ if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ mPlayer.setAudioAttributes(
+ new AudioAttributes.Builder()
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .build());
+ } else {
+ mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
+ }
+ mPlayer.setDataSource(mediaFilePath);
+ mPlayer.prepare(); // Synchronous call blocks until the player is ready for playback.
+ mPlayer.start();
+ }
+
+ @Rpc(description = "Stops media playback.")
+ public void mediaStop() throws IOException {
+ mPlayer.stop();
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/NetworkingSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/NetworkingSnippet.java
new file mode 100644
index 0000000..636c0fd
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/NetworkingSnippet.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.app.DownloadManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.os.Environment;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.util.Log;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.util.List;
+import java.util.Locale;
+
+/** Snippet class for networking RPCs. */
+public class NetworkingSnippet implements Snippet {
+
+ private final Context mContext;
+ private final DownloadManager mDownloadManager;
+ private volatile boolean mIsDownloadComplete = false;
+ private volatile long mReqid = 0;
+
+ public NetworkingSnippet() {
+ mContext = InstrumentationRegistry.getInstrumentation().getContext();
+ mDownloadManager = (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
+ }
+
+ private static class NetworkingSnippetException extends Exception {
+
+ private static final long serialVersionUID = 8080L;
+
+ public NetworkingSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ @Rpc(description = "Check if a host and port are connectable using a TCP connection attempt.")
+ public boolean networkIsTcpConnectable(String host, int port) {
+ InetAddress addr;
+ try {
+ addr = InetAddress.getByName(host);
+ } catch (UnknownHostException uherr) {
+ Log.d("Host name lookup failure: " + uherr.getMessage());
+ return false;
+ }
+
+ try {
+ Socket sock = new Socket(addr, port);
+ sock.close();
+ } catch (IOException ioerr) {
+ Log.d("Did not make connection to host: " + ioerr.getMessage());
+ return false;
+ }
+ return true;
+ }
+
+ @Rpc(
+ description =
+ "Download a file using HTTP. Return content Uri (file remains on device). "
+ + "The Uri should be treated as an opaque handle for further operations.")
+ public String networkHttpDownload(String url)
+ throws IllegalArgumentException, NetworkingSnippetException {
+
+ Uri uri = Uri.parse(url);
+ List<String> pathsegments = uri.getPathSegments();
+ if (pathsegments.size() < 1) {
+ throw new IllegalArgumentException(
+ String.format(Locale.US, "The Uri %s does not have a path.", uri.toString()));
+ }
+ DownloadManager.Request request = new DownloadManager.Request(uri);
+ request.setDestinationInExternalPublicDir(
+ Environment.DIRECTORY_DOWNLOADS, pathsegments.get(pathsegments.size() - 1));
+ mIsDownloadComplete = false;
+ mReqid = 0;
+ IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
+ BroadcastReceiver receiver = new DownloadReceiver();
+ mContext.registerReceiver(receiver, filter);
+ try {
+ mReqid = mDownloadManager.enqueue(request);
+ Log.d(
+ String.format(
+ Locale.US,
+ "networkHttpDownload download of %s with id %d",
+ url,
+ mReqid));
+ if (!Utils.waitUntil(() -> mIsDownloadComplete, 30)) {
+ Log.d(
+ String.format(
+ Locale.US, "networkHttpDownload timed out waiting for completion"));
+ throw new NetworkingSnippetException("networkHttpDownload timed out.");
+ }
+ } finally {
+ mContext.unregisterReceiver(receiver);
+ }
+ Uri resp = mDownloadManager.getUriForDownloadedFile(mReqid);
+ if (resp != null) {
+ Log.d(String.format(Locale.US, "networkHttpDownload completed to %s", resp.toString()));
+ mReqid = 0;
+ return resp.toString();
+ } else {
+ Log.d(
+ String.format(
+ Locale.US,
+ "networkHttpDownload Failed to download %s",
+ uri.toString()));
+ throw new NetworkingSnippetException("networkHttpDownload didn't get downloaded file.");
+ }
+ }
+
+ private class DownloadReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ long gotid = (long) intent.getExtras().get("extra_download_id");
+ if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(action) && gotid == mReqid) {
+ mIsDownloadComplete = true;
+ }
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ if (mReqid != 0) {
+ mDownloadManager.remove(mReqid);
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/NotificationSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/NotificationSnippet.java
new file mode 100644
index 0000000..1c34264
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/NotificationSnippet.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.widget.Toast;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.rpc.RunOnUiThread;
+
+/** Snippet class exposing Android APIs related to creating notification on screen. */
+public class NotificationSnippet implements Snippet {
+
+ @RunOnUiThread
+ @Rpc(description = "Make a toast on screen.")
+ public void makeToast(String message) {
+ Toast.makeText(
+ InstrumentationRegistry.getInstrumentation().getContext(),
+ message,
+ Toast.LENGTH_LONG)
+ .show();
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/SmsSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/SmsSnippet.java
new file mode 100644
index 0000000..be41e9e
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/SmsSnippet.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import android.provider.Telephony.Sms.Intents;
+import android.telephony.SmsManager;
+import android.telephony.SmsMessage;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.android.mobly.snippet.event.EventCache;
+import com.google.android.mobly.snippet.event.SnippetEvent;
+import com.google.android.mobly.snippet.rpc.AsyncRpc;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import java.util.ArrayList;
+import org.json.JSONObject;
+
+/** Snippet class for SMS RPCs. */
+public class SmsSnippet implements Snippet {
+
+ private static class SmsSnippetException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ SmsSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ private static final int MAX_CHAR_COUNT_PER_SMS = 160;
+ private static final String SMS_SENT_ACTION = ".SMS_SENT";
+ private static final int DEFAULT_TIMEOUT_MILLISECOND = 60 * 1000;
+ private static final String SMS_RECEIVED_EVENT_NAME = "ReceivedSms";
+ private static final String SMS_SENT_EVENT_NAME = "SentSms";
+ private static final String SMS_CALLBACK_ID_PREFIX = "sendSms-";
+
+ private static int mCallbackCounter = 0;
+
+ private final Context mContext;
+ private final SmsManager mSmsManager;
+
+ public SmsSnippet() {
+ this.mContext = InstrumentationRegistry.getInstrumentation().getContext();
+ this.mSmsManager = SmsManager.getDefault();
+ }
+
+ /**
+ * Send SMS and return after waiting for send confirmation (with a timeout of 60 seconds).
+ *
+ * @param phoneNumber A String representing phone number with country code.
+ * @param message A String representing the message to send.
+ * @throws SmsSnippetException on SMS send error.
+ */
+ @Rpc(description = "Send SMS to a specified phone number.")
+ public void sendSms(String phoneNumber, String message) throws Throwable {
+ String callbackId = SMS_CALLBACK_ID_PREFIX + (++mCallbackCounter);
+ OutboundSmsReceiver receiver = new OutboundSmsReceiver(mContext, callbackId);
+
+ if (message.length() > MAX_CHAR_COUNT_PER_SMS) {
+ ArrayList<String> parts = mSmsManager.divideMessage(message);
+ ArrayList<PendingIntent> sIntents = new ArrayList<>();
+ for (int i = 0; i < parts.size(); i++) {
+ sIntents.add(
+ PendingIntent.getBroadcast(mContext, 0, new Intent(SMS_SENT_ACTION), 0));
+ }
+ receiver.setExpectedMessageCount(parts.size());
+ mContext.registerReceiver(receiver, new IntentFilter(SMS_SENT_ACTION));
+ mSmsManager.sendMultipartTextMessage(phoneNumber, null, parts, sIntents, null);
+ } else {
+ PendingIntent sentIntent =
+ PendingIntent.getBroadcast(mContext, 0, new Intent(SMS_SENT_ACTION), 0);
+ receiver.setExpectedMessageCount(1);
+ mContext.registerReceiver(receiver, new IntentFilter(SMS_SENT_ACTION));
+ mSmsManager.sendTextMessage(phoneNumber, null, message, sentIntent, null);
+ }
+
+ SnippetEvent result =
+ Utils.waitForSnippetEvent(
+ callbackId, SMS_SENT_EVENT_NAME, DEFAULT_TIMEOUT_MILLISECOND);
+
+ if (result.getData().containsKey("error")) {
+ throw new SmsSnippetException(
+ "Failed to send SMS, error code: " + result.getData().getInt("error"));
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ @AsyncRpc(description = "Async wait for incoming SMS message.")
+ public void asyncWaitForSms(String callbackId) {
+ SmsReceiver receiver = new SmsReceiver(mContext, callbackId);
+ mContext.registerReceiver(receiver, new IntentFilter(Intents.SMS_RECEIVED_ACTION));
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ @Rpc(description = "Wait for incoming SMS message.")
+ public JSONObject waitForSms(int timeoutMillis) throws Throwable {
+ String callbackId = SMS_CALLBACK_ID_PREFIX + (++mCallbackCounter);
+ SmsReceiver receiver = new SmsReceiver(mContext, callbackId);
+ mContext.registerReceiver(receiver, new IntentFilter(Intents.SMS_RECEIVED_ACTION));
+ return Utils.waitForSnippetEvent(callbackId, SMS_RECEIVED_EVENT_NAME, timeoutMillis)
+ .toJson();
+ }
+
+ @Override
+ public void shutdown() {}
+
+ private static class OutboundSmsReceiver extends BroadcastReceiver {
+ private final String mCallbackId;
+ private Context mContext;
+ private final EventCache mEventCache;
+ private int mExpectedMessageCount;
+
+ public OutboundSmsReceiver(Context context, String callbackId) {
+ this.mCallbackId = callbackId;
+ this.mContext = context;
+ this.mEventCache = EventCache.getInstance();
+ mExpectedMessageCount = 0;
+ }
+
+ public void setExpectedMessageCount(int count) {
+ mExpectedMessageCount = count;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+
+ if (SMS_SENT_ACTION.equals(action)) {
+ SnippetEvent event = new SnippetEvent(mCallbackId, SMS_SENT_EVENT_NAME);
+ switch (getResultCode()) {
+ case Activity.RESULT_OK:
+ if (mExpectedMessageCount == 1) {
+ event.getData().putBoolean("sent", true);
+ mEventCache.postEvent(event);
+ mContext.unregisterReceiver(this);
+ }
+
+ if (mExpectedMessageCount > 0) {
+ mExpectedMessageCount--;
+ }
+ break;
+ case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
+ case SmsManager.RESULT_ERROR_NO_SERVICE:
+ case SmsManager.RESULT_ERROR_NULL_PDU:
+ case SmsManager.RESULT_ERROR_RADIO_OFF:
+ event.getData().putBoolean("sent", false);
+ event.getData().putInt("error_code", getResultCode());
+ mEventCache.postEvent(event);
+ mContext.unregisterReceiver(this);
+ break;
+ default:
+ event.getData().putBoolean("sent", false);
+ event.getData().putInt("error_code", -1 /* Unknown */);
+ mEventCache.postEvent(event);
+ mContext.unregisterReceiver(this);
+ break;
+ }
+ }
+ }
+ }
+
+ private static class SmsReceiver extends BroadcastReceiver {
+ private final String mCallbackId;
+ private Context mContext;
+ private final EventCache mEventCache;
+
+ public SmsReceiver(Context context, String callbackId) {
+ this.mCallbackId = callbackId;
+ this.mContext = context;
+ this.mEventCache = EventCache.getInstance();
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ @Override
+ public void onReceive(Context receivedContext, Intent intent) {
+ if (Intents.SMS_RECEIVED_ACTION.equals(intent.getAction())) {
+ SnippetEvent event = new SnippetEvent(mCallbackId, SMS_RECEIVED_EVENT_NAME);
+ Bundle extras = intent.getExtras();
+ if (extras != null) {
+ SmsMessage[] msgs = Intents.getMessagesFromIntent(intent);
+ StringBuilder smsMsg = new StringBuilder();
+
+ SmsMessage sms = msgs[0];
+ String sender = sms.getOriginatingAddress();
+ event.getData().putString("OriginatingAddress", sender);
+
+ for (SmsMessage msg : msgs) {
+ smsMsg.append(msg.getMessageBody());
+ }
+ event.getData().putString("MessageBody", smsMsg.toString());
+ mEventCache.postEvent(event);
+ mContext.unregisterReceiver(this);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/StorageSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/StorageSnippet.java
new file mode 100644
index 0000000..23048d4
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/StorageSnippet.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.os.Environment;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.rpc.Rpc;
+
+public class StorageSnippet implements Snippet {
+
+ @Rpc(description = "Return the primary shared/external storage directory.")
+ public String storageGetExternalStorageDirectory() {
+ return Environment.getExternalStorageDirectory().getAbsolutePath();
+ }
+
+ @Rpc(description = "Return the root of the \"system\" directory.")
+ public String storageGetRootDirectory() {
+ return Environment.getRootDirectory().getAbsolutePath();
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/TelephonySnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/TelephonySnippet.java
new file mode 100644
index 0000000..21c5d1e
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/TelephonySnippet.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.content.Context;
+import android.telephony.TelephonyManager;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.rpc.Rpc;
+
+/** Snippet class for telephony RPCs. */
+public class TelephonySnippet implements Snippet {
+
+ private final TelephonyManager mTelephonyManager;
+
+ public TelephonySnippet() {
+ Context context = InstrumentationRegistry.getInstrumentation().getContext();
+ mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+ }
+
+ @Rpc(description = "Gets the line 1 phone number.")
+ public String getLine1Number() {
+ return mTelephonyManager.getLine1Number();
+ }
+
+ @Rpc(description = "Returns the unique subscriber ID, for example, the IMSI for a GSM phone.")
+ public String getSubscriberId() {
+ return mTelephonyManager.getSubscriberId();
+ }
+
+ @Rpc(
+ description =
+ "Gets the call state for the default subscription. Call state values are"
+ + "0: IDLE, 1: RINGING, 2: OFFHOOK")
+ public int getTelephonyCallState() {
+ return mTelephonyManager.getCallState();
+ }
+
+ @Rpc(
+ description =
+ "Returns a constant indicating the radio technology (network type) currently"
+ + "in use on the device for data transmission.")
+ public int getDataNetworkType() {
+ return mTelephonyManager.getDataNetworkType();
+ }
+
+ @Rpc(
+ description =
+ "Returns a constant indicating the radio technology (network type) currently"
+ + "in use on the device for voice transmission.")
+ public int getVoiceNetworkType() {
+ return mTelephonyManager.getVoiceNetworkType();
+ }
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/WifiManagerSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/WifiManagerSnippet.java
new file mode 100644
index 0000000..cf577c3
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/WifiManagerSnippet.java
@@ -0,0 +1,434 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled;
+
+import android.app.UiAutomation;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.wifi.ScanResult;
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.os.Build;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.utils.JsonDeserializer;
+import com.google.android.mobly.snippet.bundled.utils.JsonSerializer;
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.rpc.RpcMinSdk;
+import com.google.android.mobly.snippet.util.Log;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import android.net.wifi.SupplicantState;
+/** Snippet class exposing Android APIs in WifiManager. */
+public class WifiManagerSnippet implements Snippet {
+ private static class WifiManagerSnippetException extends Exception {
+ private static final long serialVersionUID = 1;
+
+ public WifiManagerSnippetException(String msg) {
+ super(msg);
+ }
+
+ public WifiManagerSnippetException(String msg, Throwable err) {
+ super(msg, err);
+ }
+ }
+
+ private static final int TIMEOUT_TOGGLE_STATE = 30;
+ private final WifiManager mWifiManager;
+ private final Context mContext;
+ private final JsonSerializer mJsonSerializer = new JsonSerializer();
+ private volatile boolean mIsScanResultAvailable = false;
+
+ public WifiManagerSnippet() throws Throwable {
+ mContext = InstrumentationRegistry.getInstrumentation().getContext();
+ mWifiManager =
+ (WifiManager)
+ mContext.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ adaptShellPermissionIfRequired();
+ }
+
+ @Rpc(
+ description =
+ "Clears all configured networks. This will only work if all configured "
+ + "networks were added through this MBS instance")
+ public void wifiClearConfiguredNetworks() throws WifiManagerSnippetException {
+ List<WifiConfiguration> unremovedConfigs = mWifiManager.getConfiguredNetworks();
+ List<WifiConfiguration> failedConfigs = new ArrayList<>();
+ if (unremovedConfigs == null) {
+ throw new WifiManagerSnippetException(
+ "Failed to get a list of configured networks. Is wifi disabled?");
+ }
+ for (WifiConfiguration config : unremovedConfigs) {
+ if (!mWifiManager.removeNetwork(config.networkId)) {
+ failedConfigs.add(config);
+ }
+ }
+ if (!failedConfigs.isEmpty()) {
+ throw new WifiManagerSnippetException("Failed to remove networks: " + failedConfigs);
+ }
+ }
+
+ @Rpc(description = "Turns on Wi-Fi with a 30s timeout.")
+ public void wifiEnable() throws InterruptedException, WifiManagerSnippetException {
+ if (mWifiManager.getWifiState() == WifiManager.WIFI_STATE_ENABLED) {
+ return;
+ }
+ // If Wi-Fi is trying to turn off, wait for that to complete before continuing.
+ if (mWifiManager.getWifiState() == WifiManager.WIFI_STATE_DISABLING) {
+ if (!Utils.waitUntil(
+ () -> mWifiManager.getWifiState() == WifiManager.WIFI_STATE_DISABLED,
+ TIMEOUT_TOGGLE_STATE)) {
+ Log.e(String.format("Wi-Fi failed to stabilize after %ss.", TIMEOUT_TOGGLE_STATE));
+ }
+ }
+ if (!mWifiManager.setWifiEnabled(true)) {
+ throw new WifiManagerSnippetException("Failed to initiate enabling Wi-Fi.");
+ }
+ if (!Utils.waitUntil(
+ () -> mWifiManager.getWifiState() == WifiManager.WIFI_STATE_ENABLED,
+ TIMEOUT_TOGGLE_STATE)) {
+ throw new WifiManagerSnippetException(
+ String.format(
+ "Failed to enable Wi-Fi after %ss, timeout!", TIMEOUT_TOGGLE_STATE));
+ }
+ }
+
+ @Rpc(description = "Turns off Wi-Fi with a 30s timeout.")
+ public void wifiDisable() throws InterruptedException, WifiManagerSnippetException {
+ if (mWifiManager.getWifiState() == WifiManager.WIFI_STATE_DISABLED) {
+ return;
+ }
+ // If Wi-Fi is trying to turn on, wait for that to complete before continuing.
+ if (mWifiManager.getWifiState() == WifiManager.WIFI_STATE_ENABLING) {
+ if (!Utils.waitUntil(
+ () -> mWifiManager.getWifiState() == WifiManager.WIFI_STATE_ENABLED,
+ TIMEOUT_TOGGLE_STATE)) {
+ Log.e(String.format("Wi-Fi failed to stabilize after %ss.", TIMEOUT_TOGGLE_STATE));
+ }
+ }
+ if (!mWifiManager.setWifiEnabled(false)) {
+ throw new WifiManagerSnippetException("Failed to initiate disabling Wi-Fi.");
+ }
+ if (!Utils.waitUntil(
+ () -> mWifiManager.getWifiState() == WifiManager.WIFI_STATE_DISABLED,
+ TIMEOUT_TOGGLE_STATE)) {
+ throw new WifiManagerSnippetException(
+ String.format(
+ "Failed to disable Wi-Fi after %ss, timeout!", TIMEOUT_TOGGLE_STATE));
+ }
+ }
+
+ @Rpc(description = "Checks if Wi-Fi is enabled.")
+ public boolean wifiIsEnabled() {
+ return mWifiManager.getWifiState() == WifiManager.WIFI_STATE_ENABLED;
+ }
+
+ @Rpc(description = "Trigger Wi-Fi scan.")
+ public void wifiStartScan() throws WifiManagerSnippetException {
+ if (!mWifiManager.startScan()) {
+ throw new WifiManagerSnippetException("Failed to initiate Wi-Fi scan.");
+ }
+ }
+
+ @Rpc(
+ description =
+ "Get Wi-Fi scan results, which is a list of serialized WifiScanResult objects.")
+ public JSONArray wifiGetCachedScanResults() throws JSONException {
+ JSONArray results = new JSONArray();
+ for (ScanResult result : mWifiManager.getScanResults()) {
+ results.put(mJsonSerializer.toJson(result));
+ }
+ return results;
+ }
+
+ @Rpc(
+ description =
+ "Start scan, wait for scan to complete, and return results, which is a list of "
+ + "serialized WifiScanResult objects.")
+ public JSONArray wifiScanAndGetResults()
+ throws InterruptedException, JSONException, WifiManagerSnippetException {
+ mContext.registerReceiver(
+ new WifiScanReceiver(),
+ new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION));
+ wifiStartScan();
+ mIsScanResultAvailable = false;
+ if (!Utils.waitUntil(() -> mIsScanResultAvailable, 2 * 60)) {
+ throw new WifiManagerSnippetException(
+ "Failed to get scan results after 2min, timeout!");
+ }
+ return wifiGetCachedScanResults();
+ }
+
+ @Rpc(
+ description =
+ "Connects to a Wi-Fi network. This covers the common network types like open and "
+ + "WPA2.")
+ public void wifiConnectSimple(String ssid, @Nullable String password)
+ throws InterruptedException, JSONException, WifiManagerSnippetException {
+ JSONObject config = new JSONObject();
+ config.put("SSID", ssid);
+ if (password != null) {
+ config.put("password", password);
+ }
+ wifiConnect(config);
+ }
+
+ /**
+ * Gets the {@link WifiConfiguration} of a Wi-Fi network that has already been configured.
+ *
+ * <p>If the network has not been configured, returns null.
+ *
+ * <p>A network is configured if a WifiConfiguration was created for it and added with {@link
+ * WifiManager#addNetwork(WifiConfiguration)}.
+ */
+ private WifiConfiguration getExistingConfiguredNetwork(String ssid) {
+ List<WifiConfiguration> wifiConfigs = mWifiManager.getConfiguredNetworks();
+ if (wifiConfigs == null) {
+ return null;
+ }
+ for (WifiConfiguration config : wifiConfigs) {
+ if (config.SSID.equals(ssid)) {
+ return config;
+ }
+ }
+ return null;
+ }
+ /**
+ * Connect to a Wi-Fi network.
+ *
+ * @param wifiNetworkConfig A JSON object that contains the info required to connect to a Wi-Fi
+ * network. It follows the fields of WifiConfiguration type, e.g. {"SSID": "myWifi",
+ * "password": "12345678"}.
+ * @throws InterruptedException
+ * @throws JSONException
+ * @throws WifiManagerSnippetException
+ */
+ @Rpc(description = "Connects to a Wi-Fi network.")
+ public void wifiConnect(JSONObject wifiNetworkConfig)
+ throws InterruptedException, JSONException, WifiManagerSnippetException {
+ Log.d("Got network config: " + wifiNetworkConfig);
+ WifiConfiguration wifiConfig = JsonDeserializer.jsonToWifiConfig(wifiNetworkConfig);
+ String SSID = wifiConfig.SSID;
+ // Return directly if network is already connected.
+ WifiInfo connectionInfo = mWifiManager.getConnectionInfo();
+ if (connectionInfo.getNetworkId() != -1
+ && connectionInfo.getSSID().equals(wifiConfig.SSID)) {
+ Log.d("Network " + connectionInfo.getSSID() + " is already connected.");
+ return;
+ }
+ int networkId;
+ // If this is a network with a known SSID, connect with the existing config.
+ // We have to do this because in N+, network configs can only be modified by the UID that
+ // created the network. So any attempt to modify a network config that does not belong to us
+ // would result in error.
+ WifiConfiguration existingConfig = getExistingConfiguredNetwork(wifiConfig.SSID);
+ if (existingConfig != null) {
+ Log.w(
+ "Connecting to network \""
+ + existingConfig.SSID
+ + "\" with its existing configuration: "
+ + existingConfig.toString());
+ wifiConfig = existingConfig;
+ networkId = wifiConfig.networkId;
+ } else {
+ // If this is a network with a new SSID, add the network.
+ networkId = mWifiManager.addNetwork(wifiConfig);
+ }
+ mWifiManager.disconnect();
+ if (!mWifiManager.enableNetwork(networkId, true)) {
+ throw new WifiManagerSnippetException(
+ "Failed to enable Wi-Fi network of ID: " + networkId);
+ }
+ if (!mWifiManager.reconnect()) {
+ throw new WifiManagerSnippetException(
+ "Failed to reconnect to Wi-Fi network of ID: " + networkId);
+ }
+ if (!Utils.waitUntil(
+ () ->
+ mWifiManager.getConnectionInfo().getSSID().equals(SSID)
+ && mWifiManager.getConnectionInfo().getNetworkId() != -1 && mWifiManager
+ .getConnectionInfo().getSupplicantState().equals(SupplicantState.COMPLETED),
+ 90)) {
+ throw new WifiManagerSnippetException(
+ String.format(
+ "Failed to connect to '%s', timeout! Current connection: '%s'",
+ wifiNetworkConfig, mWifiManager.getConnectionInfo().getSSID()));
+ }
+ Log.d(
+ "Connected to network '"
+ + mWifiManager.getConnectionInfo().getSSID()
+ + "' with ID "
+ + mWifiManager.getConnectionInfo().getNetworkId());
+ }
+
+ @Rpc(
+ description =
+ "Forget a configured Wi-Fi network by its network ID, which is part of the"
+ + " WifiConfiguration.")
+ public void wifiRemoveNetwork(Integer networkId) throws WifiManagerSnippetException {
+ if (!mWifiManager.removeNetwork(networkId)) {
+ throw new WifiManagerSnippetException("Failed to remove network of ID: " + networkId);
+ }
+ }
+
+ @Rpc(
+ description =
+ "Get the list of configured Wi-Fi networks, each is a serialized "
+ + "WifiConfiguration object.")
+ public List<JSONObject> wifiGetConfiguredNetworks() throws JSONException {
+ List<JSONObject> networks = new ArrayList<>();
+ for (WifiConfiguration config : mWifiManager.getConfiguredNetworks()) {
+ networks.add(mJsonSerializer.toJson(config));
+ }
+ return networks;
+ }
+
+ @RpcMinSdk(Build.VERSION_CODES.LOLLIPOP)
+ @Rpc(description = "Enable or disable wifi verbose logging.")
+ public void wifiSetVerboseLogging(boolean enable) throws Throwable {
+ Utils.invokeByReflection(mWifiManager, "enableVerboseLogging", enable ? 1 : 0);
+ }
+
+ @Rpc(
+ description =
+ "Get the information about the active Wi-Fi connection, which is a serialized "
+ + "WifiInfo object.")
+ public JSONObject wifiGetConnectionInfo() throws JSONException {
+ return mJsonSerializer.toJson(mWifiManager.getConnectionInfo());
+ }
+
+ @Rpc(
+ description =
+ "Get the info from last successful DHCP request, which is a serialized DhcpInfo "
+ + "object.")
+ public JSONObject wifiGetDhcpInfo() throws JSONException {
+ return mJsonSerializer.toJson(mWifiManager.getDhcpInfo());
+ }
+
+ @Rpc(description = "Check whether Wi-Fi Soft AP (hotspot) is enabled.")
+ public boolean wifiIsApEnabled() throws Throwable {
+ return (boolean) Utils.invokeByReflection(mWifiManager, "isWifiApEnabled");
+ }
+
+ @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+ @RpcMinSdk(Build.VERSION_CODES.LOLLIPOP)
+ @Rpc(
+ description =
+ "Check whether this device supports 5 GHz band Wi-Fi. "
+ + "Turn on Wi-Fi before calling.")
+ public boolean wifiIs5GHzBandSupported() {
+ return mWifiManager.is5GHzBandSupported();
+ }
+
+ /**
+ * Enable Wi-Fi Soft AP (hotspot).
+ *
+ * @param configuration The same format as the param wifiNetworkConfig param for wifiConnect.
+ * @throws Throwable
+ */
+ @Rpc(description = "Enable Wi-Fi Soft AP (hotspot).")
+ public void wifiEnableSoftAp(@Nullable JSONObject configuration) throws Throwable {
+ // If no configuration is provided, the existing configuration would be used.
+ WifiConfiguration wifiConfiguration = null;
+ if (configuration != null) {
+ wifiConfiguration = JsonDeserializer.jsonToWifiConfig(configuration);
+ // Have to trim off the extra quotation marks since Soft AP logic interprets
+ // WifiConfiguration.SSID literally, unlike the WifiManager connection logic.
+ wifiConfiguration.SSID = JsonSerializer.trimQuotationMarks(wifiConfiguration.SSID);
+ }
+ if (!(boolean)
+ Utils.invokeByReflection(
+ mWifiManager, "setWifiApEnabled", wifiConfiguration, true)) {
+ throw new WifiManagerSnippetException("Failed to initiate turning on Wi-Fi Soft AP.");
+ }
+ if (!Utils.waitUntil(() -> wifiIsApEnabled() == true, 60)) {
+ throw new WifiManagerSnippetException(
+ "Timed out after 60s waiting for Wi-Fi Soft AP state to turn on with configuration: "
+ + configuration);
+ }
+ }
+
+ /** Disables Wi-Fi Soft AP (hotspot). */
+ @Rpc(description = "Disable Wi-Fi Soft AP (hotspot).")
+ public void wifiDisableSoftAp() throws Throwable {
+ if (!(boolean)
+ Utils.invokeByReflection(
+ mWifiManager,
+ "setWifiApEnabled",
+ null /* No configuration needed for disabling */,
+ false)) {
+ throw new WifiManagerSnippetException("Failed to initiate turning off Wi-Fi Soft AP.");
+ }
+ if (!Utils.waitUntil(() -> wifiIsApEnabled() == false, 60)) {
+ throw new WifiManagerSnippetException(
+ "Timed out after 60s waiting for Wi-Fi Soft AP state to turn off.");
+ }
+ }
+
+ @Override
+ public void shutdown() {}
+
+ /**
+ * Elevates permission as require for proper wifi controls.
+ *
+ * Starting in Android Q (29), additional restrictions are added for wifi operation. See
+ * below Android Q privacy changes for additional details.
+ * https://developer.android.com/preview/privacy/camera-connectivity
+ *
+ * @throws Throwable if failed to cleanup connection with UiAutomation
+ */
+ private void adaptShellPermissionIfRequired() throws Throwable {
+ if (mContext.getApplicationContext().getApplicationInfo().targetSdkVersion >= 29
+ && Build.VERSION.SDK_INT >= 29) {
+ Log.d("Elevating permission require to enable support for wifi operation in Android Q+");
+ UiAutomation uia = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+ uia.adoptShellPermissionIdentity();
+ try {
+ Class<?> cls = Class.forName("android.app.UiAutomation");
+ Method destroyMethod = cls.getDeclaredMethod("destroy");
+ destroyMethod.invoke(uia);
+ } catch (NoSuchMethodException
+ | IllegalAccessException
+ | ClassNotFoundException
+ | InvocationTargetException e) {
+ throw new WifiManagerSnippetException("Failed to cleaup Ui Automation", e);
+ }
+ }
+ }
+
+ private class WifiScanReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context c, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)) {
+ mIsScanResultAvailable = true;
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/BluetoothAdapterSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/BluetoothAdapterSnippet.java
new file mode 100644
index 0000000..6e66e43
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/BluetoothAdapterSnippet.java
@@ -0,0 +1,361 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.utils.JsonSerializer;
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.util.Log;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.concurrent.ConcurrentHashMap;
+import org.json.JSONException;
+
+/** Snippet class exposing Android APIs in BluetoothAdapter. */
+public class BluetoothAdapterSnippet implements Snippet {
+
+ private static class BluetoothAdapterSnippetException extends Exception {
+
+ private static final long serialVersionUID = 1;
+
+ public BluetoothAdapterSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ // Timeout to measure consistent BT state.
+ private static final int BT_MATCHING_STATE_INTERVAL_SEC = 5;
+ // Default timeout in seconds.
+ private static final int TIMEOUT_TOGGLE_STATE_SEC = 30;
+ private final Context mContext;
+ private static final BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+ private final JsonSerializer mJsonSerializer = new JsonSerializer();
+ private static final ConcurrentHashMap<String, BluetoothDevice> mDiscoveryResults =
+ new ConcurrentHashMap<>();
+ private volatile boolean mIsDiscoveryFinished = false;
+
+ public BluetoothAdapterSnippet() {
+ mContext = InstrumentationRegistry.getInstrumentation().getContext();
+ }
+
+ /**
+ * Gets a {@link BluetoothDevice} that has either been paired or discovered.
+ *
+ * @param deviceAddress
+ * @return
+ */
+ public static BluetoothDevice getKnownDeviceByAddress(String deviceAddress) {
+ BluetoothDevice pairedDevice = getPairedDeviceByAddress(deviceAddress);
+ if (pairedDevice != null) {
+ return pairedDevice;
+ }
+ BluetoothDevice discoveredDevice = mDiscoveryResults.get(deviceAddress);
+ if (discoveredDevice != null) {
+ return discoveredDevice;
+ }
+ throw new NoSuchElementException(
+ "No device with address "
+ + deviceAddress
+ + " is paired or has been discovered. Cannot proceed.");
+ }
+
+ private static BluetoothDevice getPairedDeviceByAddress(String deviceAddress) {
+ for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) {
+ if (device.getAddress().equalsIgnoreCase(deviceAddress)) {
+ return device;
+ }
+ }
+ return null;
+ }
+
+ @Rpc(description = "Enable bluetooth with a 30s timeout.")
+ public void btEnable() throws BluetoothAdapterSnippetException, InterruptedException {
+ if (mBluetoothAdapter.getState() == BluetoothAdapter.STATE_ON) {
+ return;
+ }
+ waitForStableBtState();
+
+ if (!mBluetoothAdapter.enable()) {
+ throw new BluetoothAdapterSnippetException("Failed to start enabling bluetooth.");
+ }
+ if (!Utils.waitUntil(
+ () -> mBluetoothAdapter.getState() == BluetoothAdapter.STATE_ON,
+ TIMEOUT_TOGGLE_STATE_SEC)) {
+ throw new BluetoothAdapterSnippetException(
+ String.format(
+ "Bluetooth did not turn on within %ss.", TIMEOUT_TOGGLE_STATE_SEC));
+ }
+ }
+
+ @Rpc(description = "Disable bluetooth with a 30s timeout.")
+ public void btDisable() throws BluetoothAdapterSnippetException, InterruptedException {
+ if (mBluetoothAdapter.getState() == BluetoothAdapter.STATE_OFF) {
+ return;
+ }
+ waitForStableBtState();
+ if (!mBluetoothAdapter.disable()) {
+ throw new BluetoothAdapterSnippetException("Failed to start disabling bluetooth.");
+ }
+ if (!Utils.waitUntil(
+ () -> mBluetoothAdapter.getState() == BluetoothAdapter.STATE_OFF,
+ TIMEOUT_TOGGLE_STATE_SEC)) {
+ throw new BluetoothAdapterSnippetException(
+ String.format(
+ "Bluetooth did not turn off within %ss.", TIMEOUT_TOGGLE_STATE_SEC));
+ }
+ }
+
+ @Rpc(description = "Return true if Bluetooth is enabled, false otherwise.")
+ public boolean btIsEnabled() {
+ return mBluetoothAdapter.isEnabled();
+ }
+
+ @Rpc(
+ description =
+ "Get bluetooth discovery results, which is a list of serialized BluetoothDevice objects.")
+ public ArrayList<Bundle> btGetCachedScanResults() {
+ return mJsonSerializer.serializeBluetoothDeviceList(mDiscoveryResults.values());
+ }
+
+ @Rpc(description = "Set the friendly Bluetooth name of the local Bluetooth adapter.")
+ public void btSetName(String name) throws BluetoothAdapterSnippetException {
+ if (!btIsEnabled()) {
+ throw new BluetoothAdapterSnippetException(
+ "Bluetooth is not enabled, cannot set Bluetooth name.");
+ }
+ if (!mBluetoothAdapter.setName(name)) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to set local Bluetooth name to " + name);
+ }
+ }
+
+ @Rpc(description = "Get the friendly Bluetooth name of the local Bluetooth adapter.")
+ public String btGetName() {
+ return mBluetoothAdapter.getName();
+ }
+
+ @Rpc(description = "Returns the hardware address of the local Bluetooth adapter.")
+ public String btGetAddress() {
+ return mBluetoothAdapter.getAddress();
+ }
+
+ @Rpc(
+ description =
+ "Start discovery, wait for discovery to complete, and return results, which is a list of "
+ + "serialized BluetoothDevice objects.")
+ public List<Bundle> btDiscoverAndGetResults()
+ throws InterruptedException, BluetoothAdapterSnippetException {
+ IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND);
+ filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
+ if (mBluetoothAdapter.isDiscovering()) {
+ mBluetoothAdapter.cancelDiscovery();
+ }
+ mDiscoveryResults.clear();
+ mIsDiscoveryFinished = false;
+ BroadcastReceiver receiver = new BluetoothScanReceiver();
+ mContext.registerReceiver(receiver, filter);
+ try {
+ if (!mBluetoothAdapter.startDiscovery()) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to initiate Bluetooth Discovery.");
+ }
+ if (!Utils.waitUntil(() -> mIsDiscoveryFinished, 120)) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to get discovery results after 2 mins, timeout!");
+ }
+ } finally {
+ mContext.unregisterReceiver(receiver);
+ }
+ return btGetCachedScanResults();
+ }
+
+ @Rpc(description = "Become discoverable in Bluetooth.")
+ public void btBecomeDiscoverable(Integer duration) throws Throwable {
+ if (!btIsEnabled()) {
+ throw new BluetoothAdapterSnippetException(
+ "Bluetooth is not enabled, cannot become discoverable.");
+ }
+ if (Build.VERSION.SDK_INT > 29) {
+ if (!(boolean)
+ Utils.invokeByReflection(
+ mBluetoothAdapter,
+ "setScanMode",
+ BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE,
+ (long) duration * 1000)) {
+ throw new BluetoothAdapterSnippetException("Failed to become discoverable.");
+ } else {
+ if (!(boolean)
+ Utils.invokeByReflection(
+ mBluetoothAdapter,
+ "setScanMode",
+ BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE,
+ duration)) {
+ throw new BluetoothAdapterSnippetException("Failed to become discoverable.");
+ }
+ }
+ }
+ }
+
+ @Rpc(description = "Cancel ongoing bluetooth discovery.")
+ public void btCancelDiscovery() throws BluetoothAdapterSnippetException {
+ if (!mBluetoothAdapter.isDiscovering()) {
+ Log.d("No ongoing bluetooth discovery.");
+ return;
+ }
+ IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
+ mIsDiscoveryFinished = false;
+ BroadcastReceiver receiver = new BluetoothScanReceiver();
+ mContext.registerReceiver(receiver, filter);
+ try {
+ if (!mBluetoothAdapter.cancelDiscovery()) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to initiate to cancel bluetooth discovery.");
+ }
+ if (!Utils.waitUntil(() -> mIsDiscoveryFinished, 120)) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to get discovery results after 2 mins, timeout!");
+ }
+ } finally {
+ mContext.unregisterReceiver(receiver);
+ }
+ }
+
+ @Rpc(description = "Stop being discoverable in Bluetooth.")
+ public void btStopBeingDiscoverable() throws Throwable {
+ if (!(boolean)
+ Utils.invokeByReflection(
+ mBluetoothAdapter,
+ "setScanMode",
+ BluetoothAdapter.SCAN_MODE_NONE,
+ 0 /* duration is not used for this */)) {
+ throw new BluetoothAdapterSnippetException("Failed to stop being discoverable.");
+ }
+ }
+
+ @Rpc(description = "Get the list of paired bluetooth devices.")
+ public List<Bundle> btGetPairedDevices()
+ throws BluetoothAdapterSnippetException, InterruptedException, JSONException {
+ ArrayList<Bundle> pairedDevices = new ArrayList<>();
+ for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) {
+ pairedDevices.add(mJsonSerializer.serializeBluetoothDevice(device));
+ }
+ return pairedDevices;
+ }
+
+ @Rpc(description = "Pair with a bluetooth device.")
+ public void btPairDevice(String deviceAddress) throws Throwable {
+ BluetoothDevice device = mDiscoveryResults.get(deviceAddress);
+ if (device == null) {
+ throw new NoSuchElementException(
+ "No device with address "
+ + deviceAddress
+ + " has been discovered. Cannot proceed.");
+ }
+ mContext.registerReceiver(
+ new PairingBroadcastReceiver(mContext), PairingBroadcastReceiver.filter);
+ if (!(boolean) Utils.invokeByReflection(device, "createBond")) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to initiate the pairing process to device: " + deviceAddress);
+ }
+ if (!Utils.waitUntil(() -> device.getBondState() == BluetoothDevice.BOND_BONDED, 120)) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to pair with device " + deviceAddress + " after 2min.");
+ }
+ }
+
+ @Rpc(description = "Un-pair a bluetooth device.")
+ public void btUnpairDevice(String deviceAddress) throws Throwable {
+ for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) {
+ if (device.getAddress().equalsIgnoreCase(deviceAddress)) {
+ if (!(boolean) Utils.invokeByReflection(device, "removeBond")) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to initiate the un-pairing process for device: "
+ + deviceAddress);
+ }
+ if (!Utils.waitUntil(
+ () -> device.getBondState() == BluetoothDevice.BOND_NONE, 30)) {
+ throw new BluetoothAdapterSnippetException(
+ "Failed to un-pair device " + deviceAddress + " after 30s.");
+ }
+ return;
+ }
+ }
+ throw new NoSuchElementException("No device wih address " + deviceAddress + " is paired.");
+ }
+
+ @Override
+ public void shutdown() {}
+
+ private class BluetoothScanReceiver extends BroadcastReceiver {
+
+ /**
+ * The receiver gets an ACTION_FOUND intent whenever a new device is found.
+ * ACTION_DISCOVERY_FINISHED intent is received when the discovery process ends.
+ */
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(action)) {
+ mIsDiscoveryFinished = true;
+ } else if (BluetoothDevice.ACTION_FOUND.equals(action)) {
+ BluetoothDevice device =
+ (BluetoothDevice) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ mDiscoveryResults.put(device.getAddress(), device);
+ }
+ }
+ }
+
+ /**
+ * Waits until the bluetooth adapter state has stabilized. We consider BT state stabilized if it
+ * hasn't changed within 5 sec.
+ */
+ private static void waitForStableBtState() throws BluetoothAdapterSnippetException {
+ long timeoutMs = System.currentTimeMillis() + TIMEOUT_TOGGLE_STATE_SEC * 1000;
+ long continuousStateIntervalMs =
+ System.currentTimeMillis() + BT_MATCHING_STATE_INTERVAL_SEC * 1000;
+ int prevState = mBluetoothAdapter.getState();
+ while (System.currentTimeMillis() < timeoutMs) {
+ // Delay.
+ Utils.waitUntil(() -> false, /* timeout= */ 1);
+
+ int currentState = mBluetoothAdapter.getState();
+ if (currentState != prevState) {
+ continuousStateIntervalMs =
+ System.currentTimeMillis() + BT_MATCHING_STATE_INTERVAL_SEC * 1000;
+ }
+ if (continuousStateIntervalMs <= System.currentTimeMillis()) {
+ return;
+ }
+ prevState = currentState;
+ }
+ throw new BluetoothAdapterSnippetException(
+ String.format(
+ "Failed to reach a stable Bluetooth state within %d s",
+ TIMEOUT_TOGGLE_STATE_SEC));
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/PairingBroadcastReceiver.java b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/PairingBroadcastReceiver.java
new file mode 100644
index 0000000..0cfd362
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/PairingBroadcastReceiver.java
@@ -0,0 +1,30 @@
+package com.google.android.mobly.snippet.bundled.bluetooth;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import com.google.android.mobly.snippet.util.Log;
+
+@TargetApi(Build.VERSION_CODES.KITKAT)
+public class PairingBroadcastReceiver extends BroadcastReceiver {
+ private final Context mContext;
+ public static IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST);
+
+ public PairingBroadcastReceiver(Context context) {
+ mContext = context;
+ }
+
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(BluetoothDevice.ACTION_PAIRING_REQUEST)) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d("Confirming pairing with device: " + device.getAddress());
+ device.setPairingConfirmation(true);
+ mContext.unregisterReceiver(this);
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothA2dpSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothA2dpSnippet.java
new file mode 100644
index 0000000..60ed1ec
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothA2dpSnippet.java
@@ -0,0 +1,118 @@
+package com.google.android.mobly.snippet.bundled.bluetooth.profiles;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothA2dp;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.bluetooth.BluetoothAdapterSnippet;
+import com.google.android.mobly.snippet.bundled.bluetooth.PairingBroadcastReceiver;
+import com.google.android.mobly.snippet.bundled.utils.JsonSerializer;
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.rpc.RpcMinSdk;
+import java.util.ArrayList;
+
+public class BluetoothA2dpSnippet implements Snippet {
+ private static class BluetoothA2dpSnippetException extends Exception {
+ private static final long serialVersionUID = 1;
+
+ BluetoothA2dpSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ private Context mContext;
+ private static boolean sIsA2dpProfileReady = false;
+ private static BluetoothA2dp sA2dpProfile;
+ private final JsonSerializer mJsonSerializer = new JsonSerializer();
+
+ public BluetoothA2dpSnippet() {
+ mContext = InstrumentationRegistry.getInstrumentation().getContext();
+ BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+ bluetoothAdapter.getProfileProxy(
+ mContext, new A2dpServiceListener(), BluetoothProfile.A2DP);
+ Utils.waitUntil(() -> sIsA2dpProfileReady, 60);
+ }
+
+ private static class A2dpServiceListener implements BluetoothProfile.ServiceListener {
+ public void onServiceConnected(int var1, BluetoothProfile profile) {
+ sA2dpProfile = (BluetoothA2dp) profile;
+ sIsA2dpProfileReady = true;
+ }
+
+ public void onServiceDisconnected(int var1) {
+ sIsA2dpProfileReady = false;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ @RpcMinSdk(Build.VERSION_CODES.KITKAT)
+ @Rpc(
+ description =
+ "Connects to a paired or discovered device with A2DP profile."
+ + "If a device has been discovered but not paired, this will pair it.")
+ public void btA2dpConnect(String deviceAddress) throws Throwable {
+ BluetoothDevice device = BluetoothAdapterSnippet.getKnownDeviceByAddress(deviceAddress);
+ IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST);
+ mContext.registerReceiver(new PairingBroadcastReceiver(mContext), filter);
+ Utils.invokeByReflection(sA2dpProfile, "connect", device);
+ if (!Utils.waitUntil(
+ () -> sA2dpProfile.getConnectionState(device) == BluetoothA2dp.STATE_CONNECTED,
+ 120)) {
+ throw new BluetoothA2dpSnippetException(
+ "Failed to connect to device "
+ + device.getName()
+ + "|"
+ + device.getAddress()
+ + " with A2DP profile within 2min.");
+ }
+ }
+
+ @Rpc(description = "Disconnects a device from A2DP profile.")
+ public void btA2dpDisconnect(String deviceAddress) throws Throwable {
+ BluetoothDevice device = getConnectedBluetoothDevice(deviceAddress);
+ Utils.invokeByReflection(sA2dpProfile, "disconnect", device);
+ if (!Utils.waitUntil(
+ () -> sA2dpProfile.getConnectionState(device) == BluetoothA2dp.STATE_DISCONNECTED,
+ 120)) {
+ throw new BluetoothA2dpSnippetException(
+ "Failed to disconnect device "
+ + device.getName()
+ + "|"
+ + device.getAddress()
+ + " from A2DP profile within 2min.");
+ }
+ }
+
+ @Rpc(description = "Gets all the devices currently connected via A2DP profile.")
+ public ArrayList<Bundle> btA2dpGetConnectedDevices() {
+ return mJsonSerializer.serializeBluetoothDeviceList(sA2dpProfile.getConnectedDevices());
+ }
+
+ @Rpc(description = "Checks if a device is streaming audio via A2DP profile.")
+ public boolean btIsA2dpPlaying(String deviceAddress) throws Throwable {
+ BluetoothDevice device = getConnectedBluetoothDevice(deviceAddress);
+ return sA2dpProfile.isA2dpPlaying(device);
+ }
+
+ private BluetoothDevice getConnectedBluetoothDevice(String deviceAddress)
+ throws BluetoothA2dpSnippetException {
+ for (BluetoothDevice device : sA2dpProfile.getConnectedDevices()) {
+ if (device.getAddress().equalsIgnoreCase(deviceAddress)) {
+ return device;
+ }
+ }
+ throw new BluetoothA2dpSnippetException(
+ "No device with address " + deviceAddress + " is connected via A2DP.");
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothHearingAidSnippet.java b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothHearingAidSnippet.java
new file mode 100644
index 0000000..7243857
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothHearingAidSnippet.java
@@ -0,0 +1,116 @@
+package com.google.android.mobly.snippet.bundled.bluetooth.profiles;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothHearingAid;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.mobly.snippet.Snippet;
+import com.google.android.mobly.snippet.bundled.bluetooth.BluetoothAdapterSnippet;
+import com.google.android.mobly.snippet.bundled.bluetooth.PairingBroadcastReceiver;
+import com.google.android.mobly.snippet.bundled.utils.JsonSerializer;
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.android.mobly.snippet.rpc.Rpc;
+import com.google.android.mobly.snippet.rpc.RpcMinSdk;
+import com.google.common.base.Ascii;
+import java.util.ArrayList;
+
+public class BluetoothHearingAidSnippet implements Snippet {
+ private static class BluetoothHearingAidSnippetException extends Exception {
+ private static final long serialVersionUID = 1;
+
+ BluetoothHearingAidSnippetException(String msg) {
+ super(msg);
+ }
+ }
+
+ private static final int TIMEOUT_SEC = 60;
+
+ private final Context context;
+ private static boolean isHearingAidProfileReady = false;
+ private static BluetoothHearingAid hearingAidProfile;
+ private final JsonSerializer jsonSerializer = new JsonSerializer();
+
+ @TargetApi(Build.VERSION_CODES.Q)
+ public BluetoothHearingAidSnippet() {
+ context = InstrumentationRegistry.getInstrumentation().getContext();
+ BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+ bluetoothAdapter.getProfileProxy(
+ context, new HearingAidServiceListener(), BluetoothProfile.HEARING_AID);
+ Utils.waitUntil(() -> isHearingAidProfileReady, TIMEOUT_SEC);
+ }
+
+ @TargetApi(Build.VERSION_CODES.Q)
+ private static class HearingAidServiceListener implements BluetoothProfile.ServiceListener {
+ @Override
+ public void onServiceConnected(int var1, BluetoothProfile profile) {
+ hearingAidProfile = (BluetoothHearingAid) profile;
+ isHearingAidProfileReady = true;
+ }
+
+ @Override
+ public void onServiceDisconnected(int var1) {
+ isHearingAidProfileReady = false;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.Q)
+ @RpcMinSdk(Build.VERSION_CODES.Q)
+ @Rpc(description = "Connects to a paired or discovered device with HA profile.")
+ public void btHearingAidConnect(String deviceAddress) throws Throwable {
+ BluetoothDevice device = BluetoothAdapterSnippet.getKnownDeviceByAddress(deviceAddress);
+ IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST);
+ context.registerReceiver(new PairingBroadcastReceiver(context), filter);
+ Utils.invokeByReflection(hearingAidProfile, "connect", device);
+ if (!Utils.waitUntil(
+ () ->
+ hearingAidProfile.getConnectionState(device)
+ == BluetoothHearingAid.STATE_CONNECTED,
+ TIMEOUT_SEC)) {
+ throw new BluetoothHearingAidSnippetException(
+ String.format(
+ "Failed to connect to device %s|%s with HA profile within %d sec.",
+ device.getName(), device.getAddress(), TIMEOUT_SEC));
+ }
+ }
+
+ @Rpc(description = "Disconnects a device from HA profile.")
+ public void btHearingAidDisconnect(String deviceAddress) throws Throwable {
+ BluetoothDevice device = getConnectedBluetoothDevice(deviceAddress);
+ Utils.invokeByReflection(hearingAidProfile, "disconnect", device);
+ if (!Utils.waitUntil(
+ () ->
+ hearingAidProfile.getConnectionState(device)
+ == BluetoothHearingAid.STATE_DISCONNECTED,
+ TIMEOUT_SEC)) {
+ throw new BluetoothHearingAidSnippetException(
+ String.format(
+ "Failed to disconnect to device %s|%s with HA profile within %d sec.",
+ device.getName(), device.getAddress(), TIMEOUT_SEC));
+ }
+ }
+
+ @Rpc(description = "Gets all the devices currently connected via HA profile.")
+ public ArrayList<Bundle> btHearingAidGetConnectedDevices() {
+ return jsonSerializer.serializeBluetoothDeviceList(hearingAidProfile.getConnectedDevices());
+ }
+
+ private static BluetoothDevice getConnectedBluetoothDevice(String deviceAddress)
+ throws BluetoothHearingAidSnippetException {
+ for (BluetoothDevice device : hearingAidProfile.getConnectedDevices()) {
+ if (Ascii.equalsIgnoreCase(device.getAddress(), deviceAddress)) {
+ return device;
+ }
+ }
+ throw new BluetoothHearingAidSnippetException(String.format(
+ "No device with address %s is connected via HA Profile.", deviceAddress));
+ }
+
+ @Override
+ public void shutdown() {}
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonDeserializer.java b/src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonDeserializer.java
new file mode 100644
index 0000000..2f943e0
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonDeserializer.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled.utils;
+
+import android.annotation.TargetApi;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.net.wifi.WifiConfiguration;
+import android.os.Build;
+import android.os.ParcelUuid;
+import android.util.Base64;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A collection of methods used to deserialize JSON strings into data objects defined in Android
+ * API.
+ */
+public class JsonDeserializer {
+
+ private JsonDeserializer() {}
+
+ public static WifiConfiguration jsonToWifiConfig(JSONObject jsonObject) throws JSONException {
+ WifiConfiguration config = new WifiConfiguration();
+ config.SSID = "\"" + jsonObject.getString("SSID") + "\"";
+ config.hiddenSSID = jsonObject.optBoolean("hiddenSSID", false);
+ if (jsonObject.has("password")) {
+ config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK);
+ config.preSharedKey = "\"" + jsonObject.getString("password") + "\"";
+ } else {
+ config.allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE);
+ }
+ return config;
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public static AdvertiseSettings jsonToBleAdvertiseSettings(JSONObject jsonObject)
+ throws JSONException {
+ AdvertiseSettings.Builder builder = new AdvertiseSettings.Builder();
+ if (jsonObject.has("AdvertiseMode")) {
+ int mode = MbsEnums.BLE_ADVERTISE_MODE.getInt(jsonObject.getString("AdvertiseMode"));
+ builder.setAdvertiseMode(mode);
+ }
+ // Timeout in milliseconds.
+ if (jsonObject.has("Timeout")) {
+ builder.setTimeout(jsonObject.getInt("Timeout"));
+ }
+ if (jsonObject.has("Connectable")) {
+ builder.setConnectable(jsonObject.getBoolean("Connectable"));
+ }
+ if (jsonObject.has("TxPowerLevel")) {
+ int txPowerLevel =
+ MbsEnums.BLE_ADVERTISE_TX_POWER.getInt(jsonObject.getString("TxPowerLevel"));
+ builder.setTxPowerLevel(txPowerLevel);
+ }
+ return builder.build();
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public static AdvertiseData jsonToBleAdvertiseData(JSONObject jsonObject) throws JSONException {
+ AdvertiseData.Builder builder = new AdvertiseData.Builder();
+ if (jsonObject.has("IncludeDeviceName")) {
+ builder.setIncludeDeviceName(jsonObject.getBoolean("IncludeDeviceName"));
+ }
+ if (jsonObject.has("IncludeTxPowerLevel")) {
+ builder.setIncludeTxPowerLevel(jsonObject.getBoolean("IncludeTxPowerLevel"));
+ }
+ if (jsonObject.has("ServiceData")) {
+ JSONArray serviceData = jsonObject.getJSONArray("ServiceData");
+ for (int i = 0; i < serviceData.length(); i++) {
+ JSONObject dataSet = serviceData.getJSONObject(i);
+ ParcelUuid parcelUuid = ParcelUuid.fromString(dataSet.getString("UUID"));
+ builder.addServiceUuid(parcelUuid);
+ if (dataSet.has("Data")) {
+ byte[] data = Base64.decode(dataSet.getString("Data"), Base64.DEFAULT);
+ builder.addServiceData(parcelUuid, data);
+ }
+ }
+ }
+ if (jsonObject.has("ManufacturerData")) {
+ JSONObject manufacturerData = jsonObject.getJSONObject("ManufacturerData");
+ int manufacturerId = manufacturerData.getInt("ManufacturerId");
+ byte[] manufacturerSpecificData =
+ Base64.decode(jsonObject.getString("ManufacturerSpecificData"), Base64.DEFAULT);
+ builder.addManufacturerData(manufacturerId, manufacturerSpecificData);
+ }
+ return builder.build();
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonSerializer.java b/src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonSerializer.java
new file mode 100644
index 0000000..82e1e4f
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonSerializer.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled.utils;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanRecord;
+import android.net.DhcpInfo;
+import android.net.wifi.SupplicantState;
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiInfo;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.ParcelUuid;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.lang.reflect.Modifier;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collection;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A collection of methods used to serialize data types defined in Android API into JSON strings.
+ */
+public class JsonSerializer {
+ private static Gson mGson;
+
+ public JsonSerializer() {
+ GsonBuilder builder = new GsonBuilder();
+ mGson =
+ builder.serializeNulls()
+ .excludeFieldsWithModifiers(Modifier.STATIC)
+ .enableComplexMapKeySerialization()
+ .disableInnerClassSerialization()
+ .create();
+ }
+
+ /**
+ * Remove the extra quotation marks from the beginning and the end of a string.
+ *
+ * <p>This is useful for strings like the SSID field of Android's Wi-Fi configuration.
+ *
+ * @param originalString
+ */
+ public static String trimQuotationMarks(String originalString) {
+ String result = originalString;
+ if (originalString.length() > 2
+ && originalString.charAt(0) == '"'
+ && originalString.charAt(originalString.length() - 1) == '"') {
+ result = originalString.substring(1, originalString.length() - 1);
+ }
+ return result;
+ }
+
+ public JSONObject toJson(Object object) throws JSONException {
+ if (object instanceof DhcpInfo) {
+ return serializeDhcpInfo((DhcpInfo) object);
+ } else if (object instanceof WifiConfiguration) {
+ return serializeWifiConfiguration((WifiConfiguration) object);
+ } else if (object instanceof WifiInfo) {
+ return serializeWifiInfo((WifiInfo) object);
+ }
+ return defaultSerialization(object);
+ }
+
+ /**
+ * By default, we rely on Gson to do the right job.
+ *
+ * @param data An object to serialize
+ * @return A JSONObject that has the info of the serialized data object.
+ * @throws JSONException
+ */
+ private JSONObject defaultSerialization(Object data) throws JSONException {
+ return new JSONObject(mGson.toJson(data));
+ }
+
+ private JSONObject serializeDhcpInfo(DhcpInfo data) throws JSONException {
+ JSONObject result = new JSONObject(mGson.toJson(data));
+ int ipAddress = data.ipAddress;
+ byte[] addressBytes = {
+ (byte) (0xff & ipAddress),
+ (byte) (0xff & (ipAddress >> 8)),
+ (byte) (0xff & (ipAddress >> 16)),
+ (byte) (0xff & (ipAddress >> 24))
+ };
+ try {
+ String addressString = InetAddress.getByAddress(addressBytes).toString();
+ result.put("IpAddress", addressString);
+ } catch (UnknownHostException e) {
+ result.put("IpAddress", ipAddress);
+ }
+ return result;
+ }
+
+ private JSONObject serializeWifiConfiguration(WifiConfiguration data) throws JSONException {
+ JSONObject result = new JSONObject(mGson.toJson(data));
+ result.put("Status", WifiConfiguration.Status.strings[data.status]);
+ result.put("SSID", trimQuotationMarks(data.SSID));
+ return result;
+ }
+
+ private JSONObject serializeWifiInfo(WifiInfo data) throws JSONException {
+ JSONObject result = new JSONObject(mGson.toJson(data));
+ result.put("SSID", trimQuotationMarks(data.getSSID()));
+ for (SupplicantState state : SupplicantState.values()) {
+ if (data.getSupplicantState().equals(state)) {
+ result.put("SupplicantState", state.name());
+ }
+ }
+ return result;
+ }
+
+ public Bundle serializeBluetoothDevice(BluetoothDevice data) {
+ Bundle result = new Bundle();
+ result.putString("Address", data.getAddress());
+ final String bondState =
+ MbsEnums.BLUETOOTH_DEVICE_BOND_STATE.getString(data.getBondState());
+ result.putString("BondState", bondState);
+ result.putString("Name", data.getName());
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
+ String deviceType = MbsEnums.BLUETOOTH_DEVICE_TYPE.getString(data.getType());
+ result.putString("DeviceType", deviceType);
+ ParcelUuid[] parcelUuids = data.getUuids();
+ if (parcelUuids != null) {
+ ArrayList<String> uuidStrings = new ArrayList<>(parcelUuids.length);
+ for (ParcelUuid parcelUuid : parcelUuids) {
+ uuidStrings.add(parcelUuid.getUuid().toString());
+ }
+ result.putStringArrayList("UUIDs", uuidStrings);
+ }
+ }
+ return result;
+ }
+
+ public ArrayList<Bundle> serializeBluetoothDeviceList(
+ Collection<BluetoothDevice> bluetoothDevices) {
+ ArrayList<Bundle> results = new ArrayList<>();
+ for (BluetoothDevice device : bluetoothDevices) {
+ results.add(serializeBluetoothDevice(device));
+ }
+ return results;
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public Bundle serializeBleScanResult(android.bluetooth.le.ScanResult scanResult) {
+ Bundle result = new Bundle();
+ result.putBundle("Device", serializeBluetoothDevice(scanResult.getDevice()));
+ result.putInt("Rssi", scanResult.getRssi());
+ result.putBundle("ScanRecord", serializeBleScanRecord(scanResult.getScanRecord()));
+ result.putLong("TimestampNanos", scanResult.getTimestampNanos());
+ return result;
+ }
+
+ /**
+ * Serialize ScanRecord for Bluetooth LE.
+ *
+ * <p>Not all fields are serialized here. Will add more as we need.
+ *
+ * <pre>The returned {@link Bundle} has the following info:
+ * "DeviceName", String
+ * "TxPowerLevel", String
+ * </pre>
+ *
+ * @param record A {@link ScanRecord} object.
+ * @return A {@link Bundle} object.
+ */
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ private Bundle serializeBleScanRecord(ScanRecord record) {
+ Bundle result = new Bundle();
+ result.putString("DeviceName", record.getDeviceName());
+ result.putInt("TxPowerLevel", record.getTxPowerLevel());
+ return result;
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public static Bundle serializeBleAdvertisingSettings(AdvertiseSettings advertiseSettings) {
+ Bundle result = new Bundle();
+ result.putString(
+ "TxPowerLevel",
+ MbsEnums.BLE_ADVERTISE_TX_POWER.getString(advertiseSettings.getTxPowerLevel()));
+ result.putString(
+ "Mode", MbsEnums.BLE_ADVERTISE_MODE.getString(advertiseSettings.getMode()));
+ result.putInt("Timeout", advertiseSettings.getTimeout());
+ result.putBoolean("IsConnectable", advertiseSettings.isConnectable());
+ return result;
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/utils/MbsEnums.java b/src/main/java/com/google/android/mobly/snippet/bundled/utils/MbsEnums.java
new file mode 100644
index 0000000..08163b4
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/utils/MbsEnums.java
@@ -0,0 +1,92 @@
+package com.google.android.mobly.snippet.bundled.utils;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanSettings;
+import android.os.Build;
+
+/** Mobly Bundled Snippets (MBS)'s {@link RpcEnum} objects representing enums in Android APIs. */
+public class MbsEnums {
+ static final RpcEnum BLE_ADVERTISE_MODE = buildBleAdvertiseModeEnum();
+ static final RpcEnum BLE_ADVERTISE_TX_POWER = buildBleAdvertiseTxPowerEnum();
+ public static final RpcEnum BLE_SCAN_FAILED_ERROR_CODE = buildBleScanFailedErrorCodeEnum();
+ public static final RpcEnum BLE_SCAN_RESULT_CALLBACK_TYPE =
+ buildBleScanResultCallbackTypeEnum();
+ static final RpcEnum BLUETOOTH_DEVICE_BOND_STATE = buildBluetoothDeviceBondState();
+ static final RpcEnum BLUETOOTH_DEVICE_TYPE = buildBluetoothDeviceTypeEnum();
+
+ private static RpcEnum buildBluetoothDeviceBondState() {
+ RpcEnum.Builder builder = new RpcEnum.Builder();
+ return builder.add("BOND_NONE", BluetoothDevice.BOND_NONE)
+ .add("BOND_BONDING", BluetoothDevice.BOND_BONDING)
+ .add("BOND_BONDED", BluetoothDevice.BOND_BONDED)
+ .build();
+ }
+
+ private static RpcEnum buildBluetoothDeviceTypeEnum() {
+ RpcEnum.Builder builder = new RpcEnum.Builder();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) {
+ return builder.build();
+ }
+ return builder.add("DEVICE_TYPE_CLASSIC", BluetoothDevice.DEVICE_TYPE_CLASSIC)
+ .add("DEVICE_TYPE_LE", BluetoothDevice.DEVICE_TYPE_LE)
+ .add("DEVICE_TYPE_DUAL", BluetoothDevice.DEVICE_TYPE_DUAL)
+ .add("DEVICE_TYPE_UNKNOWN", BluetoothDevice.DEVICE_TYPE_UNKNOWN)
+ .build();
+ }
+
+ private static RpcEnum buildBleAdvertiseTxPowerEnum() {
+ RpcEnum.Builder builder = new RpcEnum.Builder();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ return builder.build();
+ }
+ return builder.add(
+ "ADVERTISE_TX_POWER_ULTRA_LOW",
+ AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW)
+ .add("ADVERTISE_TX_POWER_LOW", AdvertiseSettings.ADVERTISE_TX_POWER_LOW)
+ .add("ADVERTISE_TX_POWER_MEDIUM", AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
+ .add("ADVERTISE_TX_POWER_HIGH", AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+ .build();
+ }
+
+ private static RpcEnum buildBleAdvertiseModeEnum() {
+ RpcEnum.Builder builder = new RpcEnum.Builder();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ return builder.build();
+ }
+ return builder.add("ADVERTISE_MODE_BALANCED", AdvertiseSettings.ADVERTISE_MODE_BALANCED)
+ .add("ADVERTISE_MODE_LOW_LATENCY", AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
+ .add("ADVERTISE_MODE_LOW_POWER", AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
+ .build();
+ }
+
+ private static RpcEnum buildBleScanFailedErrorCodeEnum() {
+ RpcEnum.Builder builder = new RpcEnum.Builder();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ return builder.build();
+ }
+ return builder.add("SCAN_FAILED_ALREADY_STARTED", ScanCallback.SCAN_FAILED_ALREADY_STARTED)
+ .add(
+ "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED",
+ ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED)
+ .add(
+ "SCAN_FAILED_FEATURE_UNSUPPORTED",
+ ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED)
+ .add("SCAN_FAILED_INTERNAL_ERROR", ScanCallback.SCAN_FAILED_INTERNAL_ERROR)
+ .build();
+ }
+
+ private static RpcEnum buildBleScanResultCallbackTypeEnum() {
+ RpcEnum.Builder builder = new RpcEnum.Builder();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ return builder.build();
+ }
+ builder.add("CALLBACK_TYPE_ALL_MATCHES", ScanSettings.CALLBACK_TYPE_ALL_MATCHES);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ builder.add("CALLBACK_TYPE_FIRST_MATCH", ScanSettings.CALLBACK_TYPE_FIRST_MATCH);
+ builder.add("CALLBACK_TYPE_MATCH_LOST", ScanSettings.CALLBACK_TYPE_MATCH_LOST);
+ }
+ return builder.build();
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/utils/RpcEnum.java b/src/main/java/com/google/android/mobly/snippet/bundled/utils/RpcEnum.java
new file mode 100644
index 0000000..d3d95ae
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/utils/RpcEnum.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled.utils;
+
+import com.google.common.collect.ImmutableBiMap;
+
+/**
+ * A container type for handling String-Integer enum conversion in Rpc protocol.
+ *
+ * <p>In Serializing/Deserializing Android API enums, we often need to convert an enum value from
+ * one form to another. This container class makes it easier to do so.
+ *
+ * <p>Once built, an RpcEnum object is immutable.
+ */
+public class RpcEnum {
+ private final ImmutableBiMap<String, Integer> mEnums;
+
+ private RpcEnum(ImmutableBiMap.Builder<String, Integer> builder, int minSdk) {
+ mEnums = builder.build();
+ }
+
+ /**
+ * Get the int value of an enum based on its String value.
+ *
+ * @param enumString
+ * @return
+ */
+ public int getInt(String enumString) {
+ Integer result = mEnums.get(enumString);
+ if (result == null) {
+ throw new NoSuchFieldError("No int value found for: " + enumString);
+ }
+ return result;
+ }
+
+ /**
+ * Get the String value of an enum based on its int value.
+ *
+ * @param enumInt
+ * @return
+ */
+ public String getString(int enumInt) {
+ String result = mEnums.inverse().get(enumInt);
+ if (result == null) {
+ throw new NoSuchFieldError("No String value found for: " + enumInt);
+ }
+ return result;
+ }
+
+ /** Builder for RpcEnum. */
+ public static class Builder {
+ private final ImmutableBiMap.Builder<String, Integer> builder;
+ public int minSdk = 0;
+
+ public Builder() {
+ builder = new ImmutableBiMap.Builder<>();
+ }
+
+ /**
+ * Add an enum String-Integer pair.
+ *
+ * @param enumString
+ * @param enumInt
+ * @return
+ */
+ public Builder add(String enumString, int enumInt) {
+ builder.put(enumString, enumInt);
+ return this;
+ }
+
+ public RpcEnum build() {
+ return new RpcEnum(builder, minSdk);
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/mobly/snippet/bundled/utils/Utils.java b/src/main/java/com/google/android/mobly/snippet/bundled/utils/Utils.java
new file mode 100644
index 0000000..376bcb5
--- /dev/null
+++ b/src/main/java/com/google/android/mobly/snippet/bundled/utils/Utils.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.google.android.mobly.snippet.bundled.utils;
+
+import com.google.android.mobly.snippet.bundled.SmsSnippet;
+import com.google.android.mobly.snippet.event.EventCache;
+import com.google.android.mobly.snippet.event.SnippetEvent;
+import com.google.common.primitives.Primitives;
+import com.google.common.reflect.TypeToken;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Locale;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+public final class Utils {
+
+ private static final char[] hexArray = "0123456789abcdef".toCharArray();
+
+ private Utils() {}
+
+ /**
+ * Waits util a condition is met.
+ *
+ * <p>This is often used to wait for asynchronous operations to finish and the system to reach a
+ * desired state.
+ *
+ * <p>If the predicate function throws an exception and interrupts the waiting, the exception
+ * will be wrapped in an {@link RuntimeException}.
+ *
+ * @param predicate A lambda function that specifies the condition to wait for. This function
+ * should return true when the desired state has been reached.
+ * @param timeout The number of seconds to wait for before giving up.
+ * @return true if the operation finished before timeout, false otherwise.
+ */
+ public static boolean waitUntil(Utils.Predicate predicate, int timeout) {
+ timeout *= 10;
+ try {
+ while (!predicate.waitCondition() && timeout >= 0) {
+ Thread.sleep(100);
+ timeout -= 1;
+ }
+ if (predicate.waitCondition()) {
+ return true;
+ }
+ } catch (Throwable e) {
+ throw new RuntimeException(e);
+ }
+ return false;
+ }
+
+ /**
+ * Wait on a specific snippet event.
+ *
+ * <p>This allows a snippet to wait on another SnippetEvent as long as they know the name and
+ * callback id. Commonly used to make async calls synchronous, see {@link
+ * SmsSnippet#waitForSms()} waitForSms} for example usage.
+ *
+ * @param callbackId String callbackId that we want to wait on.
+ * @param eventName String event name that we are waiting on.
+ * @param timeout int timeout in milliseconds for how long it will wait for the event.
+ * @return SnippetEvent if one was received.
+ * @throws Throwable if interrupted while polling for event completion. Throws TimeoutException
+ * if no snippet event is received.
+ */
+ public static SnippetEvent waitForSnippetEvent(
+ String callbackId, String eventName, Integer timeout) throws Throwable {
+ String qId = EventCache.getQueueId(callbackId, eventName);
+ LinkedBlockingDeque<SnippetEvent> q = EventCache.getInstance().getEventDeque(qId);
+ SnippetEvent result;
+ try {
+ result = q.pollFirst(timeout, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ throw e.getCause();
+ }
+
+ if (result == null) {
+ throw new TimeoutException(
+ String.format(
+ Locale.ROOT,
+ "Timed out waiting(%d millis) for SnippetEvent: %s",
+ timeout,
+ callbackId));
+ }
+ return result;
+ }
+
+ /**
+ * A function interface that is used by lambda functions signaling an async operation is still
+ * going on.
+ */
+ public interface Predicate {
+ boolean waitCondition() throws Throwable;
+ }
+
+ /**
+ * Simplified API to invoke an instance method by reflection.
+ *
+ * <p>Sample usage:
+ *
+ * <pre>
+ * boolean result = (boolean) Utils.invokeByReflection(
+ * mWifiManager,
+ * "setWifiApEnabled", null /* wifiConfiguration * /, true /* enabled * /);
+ * </pre>
+ *
+ * @param instance Instance of object defining the method to call.
+ * @param methodName Name of the method to call. Can be inherited.
+ * @param args Variadic array of arguments to supply to the method. Their types will be used to
+ * locate a suitable method to call. Subtypes, primitive types, boxed types, and {@code
+ * null} arguments are properly handled.
+ * @return The return value of the method, or {@code null} if no return value.
+ * @throws NoSuchMethodException If no suitable method could be found.
+ * @throws Throwable The exception raised by the method, if any.
+ */
+ public static Object invokeByReflection(Object instance, String methodName, Object... args)
+ throws Throwable {
+ // Java doesn't know if invokeByReflection(instance, name, null) means that the array is
+ // null or that it's a non-null array containing a single null element. We mean the latter.
+ // Silly Java.
+ if (args == null) {
+ args = new Object[] {null};
+ }
+ // Can't use Class#getMethod(Class<?>...) because it expects that the passed in classes
+ // exactly match the parameters of the method, and doesn't handle superclasses.
+ Method method = null;
+ METHOD_SEARCHER:
+ for (Method candidateMethod : instance.getClass().getMethods()) {
+ // getMethods() returns only public methods, so we don't need to worry about checking
+ // whether the method is accessible.
+ if (!candidateMethod.getName().equals(methodName)) {
+ continue;
+ }
+ Class<?>[] declaredParams = candidateMethod.getParameterTypes();
+ if (declaredParams.length != args.length) {
+ continue;
+ }
+ for (int i = 0; i < declaredParams.length; i++) {
+ if (args[i] == null) {
+ // Null is assignable to anything except primitives.
+ if (declaredParams[i].isPrimitive()) {
+ continue METHOD_SEARCHER;
+ }
+ } else {
+ // Allow autoboxing during reflection by wrapping primitives.
+ Class<?> declaredClass = Primitives.wrap(declaredParams[i]);
+ Class<?> actualClass = Primitives.wrap(args[i].getClass());
+ TypeToken<?> declaredParamType = TypeToken.of(declaredClass);
+ TypeToken<?> actualParamType = TypeToken.of(actualClass);
+ if (!declaredParamType.isSupertypeOf(actualParamType)) {
+ continue METHOD_SEARCHER;
+ }
+ }
+ }
+ method = candidateMethod;
+ break;
+ }
+ if (method == null) {
+ StringBuilder methodString =
+ new StringBuilder(instance.getClass().getName())
+ .append('#')
+ .append(methodName)
+ .append('(');
+ for (int i = 0; i < args.length - 1; i++) {
+ methodString.append(args[i].getClass().getSimpleName()).append(", ");
+ }
+ if (args.length > 0) {
+ methodString.append(args[args.length - 1].getClass().getSimpleName());
+ }
+ methodString.append(')');
+ throw new NoSuchMethodException(methodString.toString());
+ }
+ try {
+ Object result = method.invoke(instance, args);
+ return result;
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ }
+
+ /**
+ * Convert a byte array (binary data) to a hexadecimal string (ASCII) representation.
+ *
+ * <p>[\x01\x02] -&gt; "0102"
+ *
+ * @param bytes The array of byte to convert.
+ * @return a String with the ASCII hex representation.
+ */
+ public static String bytesToHexString(byte[] bytes) {
+ char[] hexChars = new char[bytes.length * 2];
+ for (int j = 0; j < bytes.length; j++) {
+ int v = bytes[j] & 0xFF;
+ hexChars[j * 2] = hexArray[v >>> 4];
+ hexChars[j * 2 + 1] = hexArray[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+}