diff options
Diffstat (limited to 'src/main')
23 files changed, 3320 insertions, 0 deletions
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml new file mode 100644 index 0000000..21de6d7 --- /dev/null +++ b/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest + xmlns:android="http://schemas.android.com/apk/res/android" + package="com.google.android.mobly.snippet.bundled"> + + <uses-feature android:name="android.hardware.telephony" android:required="false" /> + + <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> + <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> + <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" /> + <uses-permission android:name="android.permission.BLUETOOTH" /> + <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> + <uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED" /> + <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> + <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> + <uses-permission android:name="android.permission.GET_ACCOUNTS" /> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> + <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" /> + <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> + <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" /> + <uses-permission android:name="android.permission.READ_PHONE_STATE" /> + <uses-permission android:name="android.permission.READ_PHONE_NUMBERS" /> + <uses-permission android:name="android.permission.READ_SMS" /> + <uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/> + <uses-permission android:name="android.permission.RECEIVE_SMS" /> + <uses-permission android:name="android.permission.WRITE_SETTINGS" /> + <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> + <uses-permission android:name="android.permission.SEND_SMS" /> + <application android:allowBackup="false" + android:name="androidx.multidex.MultiDexApplication"> + <meta-data + android:name="mobly-snippets" + android:value="com.google.android.mobly.snippet.bundled.AccountSnippet, + com.google.android.mobly.snippet.bundled.AudioSnippet, + com.google.android.mobly.snippet.bundled.bluetooth.BluetoothAdapterSnippet, + com.google.android.mobly.snippet.bundled.bluetooth.profiles.BluetoothA2dpSnippet, + com.google.android.mobly.snippet.bundled.bluetooth.profiles.BluetoothHearingAidSnippet, + com.google.android.mobly.snippet.bundled.BluetoothLeAdvertiserSnippet, + com.google.android.mobly.snippet.bundled.BluetoothLeScannerSnippet, + com.google.android.mobly.snippet.bundled.LogSnippet, + com.google.android.mobly.snippet.bundled.MediaSnippet, + com.google.android.mobly.snippet.bundled.NotificationSnippet, + com.google.android.mobly.snippet.bundled.TelephonySnippet, + com.google.android.mobly.snippet.bundled.NetworkingSnippet, + com.google.android.mobly.snippet.bundled.FileSnippet, + com.google.android.mobly.snippet.bundled.SmsSnippet, + com.google.android.mobly.snippet.bundled.WifiManagerSnippet, + com.google.android.mobly.snippet.bundled.StorageSnippet" /> + </application> + + <instrumentation + android:name="com.google.android.mobly.snippet.SnippetRunner" + android:targetPackage="com.google.android.mobly.snippet.bundled" /> +</manifest> 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] -> "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); + } +} |