aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorfrankfeng <frankfeng@google.com>2021-12-23 22:34:24 +0000
committerAutomerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>2021-12-23 22:34:24 +0000
commit36217304e1582ed36c7a365d9a94c9f1de2074cc (patch)
tree71f65f7f55b0604e60173d7004994baa85e97c35
parentbcbece2f1ebff711bbb8358a4dc709420d93e3a1 (diff)
parent233a9846391b42adcaa36eea9f39980dedb43be2 (diff)
downloadmobly-bundled-snippets-36217304e1582ed36c7a365d9a94c9f1de2074cc.tar.gz
Merge remote-tracking branch 'aosp/upstream-master' into mymerge am: b3028abea8 am: 233a984639
Original change: https://android-review.googlesource.com/c/platform/external/mobly-bundled-snippets/+/1932819 Change-Id: Ic1a6fdac0d89d494e936b3033f3bb1b68dae6f4e
-rw-r--r--.gitignore10
-rw-r--r--CONTRIBUTING.md25
-rw-r--r--LICENSE202
-rw-r--r--METADATA19
-rw-r--r--MODULE_LICENSE_APACHE20
-rw-r--r--OWNERS3
-rw-r--r--README.md69
-rw-r--r--build.gradle111
-rw-r--r--gradle.properties4
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 53636 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties6
-rwxr-xr-xgradlew160
-rw-r--r--src/main/AndroidManifest.xml57
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/AccountSnippet.java337
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/AudioSnippet.java134
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeAdvertiserSnippet.java178
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/BluetoothLeScannerSnippet.java138
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/FileSnippet.java67
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/LogSnippet.java64
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/MediaSnippet.java66
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/NetworkingSnippet.java151
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/NotificationSnippet.java40
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/SmsSnippet.java219
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/StorageSnippet.java37
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/TelephonySnippet.java70
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/WifiManagerSnippet.java434
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/BluetoothAdapterSnippet.java361
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/PairingBroadcastReceiver.java30
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothA2dpSnippet.java118
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/bluetooth/profiles/BluetoothHearingAidSnippet.java116
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonDeserializer.java104
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/JsonSerializer.java205
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/MbsEnums.java92
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/RpcEnum.java89
-rw-r--r--src/main/java/com/google/android/mobly/snippet/bundled/utils/Utils.java213
-rw-r--r--src/test/java/UtilsTest.java120
36 files changed, 4049 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..faf530b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+*.iml
+.gradle
+/local.properties
+/.idea/
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..79d185a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,25 @@
+# How to contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to any Google project must be accompanied by a Contributor License
+Agreement. This is necessary because you own the copyright to your changes, even
+after your contribution becomes part of this project. So this agreement simply
+gives us permission to use and redistribute your contributions as part of the
+project. Head over to <https://cla.developers.google.com/> to see your current
+agreements on file or to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult [GitHub Help] for more
+information on using pull requests.
+
+[GitHub Help]: https://help.github.com/articles/about-pull-requests/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/METADATA b/METADATA
new file mode 100644
index 0000000..dc73649
--- /dev/null
+++ b/METADATA
@@ -0,0 +1,19 @@
+name: "mobly-bundled-snippets"
+description:
+ "Mobly Bundled Snippets is a set of Snippets to allow Mobly tests to "
+ "control Android devices by exposing a simplified version of the public "
+ "Android API suitable for testing."
+
+third_party {
+ url {
+ type: HOMEPAGE
+ value: "https://github.com/google/mobly-bundled-snippets"
+ }
+ url {
+ type: GIT
+ value: "https://github.com/google/mobly-bundled-snippets"
+ }
+ version: "1ff2867fb8645c5792656bd4b822d70bbce44ec2"
+ last_upgrade_date { year: 2021 month: 12 day: 8 }
+ license_type: NOTICE
+}
diff --git a/MODULE_LICENSE_APACHE2 b/MODULE_LICENSE_APACHE2
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MODULE_LICENSE_APACHE2
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..0854447
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,3 @@
+jdesprez@google.com
+frankfeng@google.com
+murj@google.com
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bb4ba01
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+Mobly Bundled Snippets is a set of Snippets to allow Mobly tests to control
+Android devices by exposing a simplified version of the public Android API
+suitable for testing.
+
+We are adding more APIs as we go. If you have specific needs for certain groups
+of APIs, feel free to file a request in [Issues](https://github.com/google/mobly-bundled-snippets/issues).
+
+Note: this is not an official Google product.
+
+
+## Usage
+
+1. Compile and install the bundled snippets
+
+ ./gradlew assembleDebug
+ adb install -d -r -g ./build/outputs/apk/debug/mobly-bundled-snippets-debug.apk
+
+1. Use the Mobly snippet shell to interact with the bundled snippets
+
+ snippet_shell.py com.google.android.mobly.snippet.bundled
+ >>> print(s.help())
+ Known methods:
+ bluetoothDisable() returns void // Disable bluetooth with a 30s timeout.
+ ...
+ wifiDisable() returns void // Turns off Wi-Fi with a 30s timeout.
+ wifiEnable() returns void // Turns on Wi-Fi with a 30s timeout.
+ ...
+
+1. To use these snippets within Mobly tests, load it on your AndroidDevice objects
+ after registering android_device module:
+
+ ```python
+ def setup_class(self):
+ self.ad = self.register_controllers(android_device, min_number=1)[0]
+ self.ad.load_snippet('api', 'com.google.android.mobly.snippet.bundled')
+
+ def test_enable_wifi(self):
+ self.ad.api.wifiEnable()
+ ```
+
+## Develop
+
+If you want to contribute, use the usual github method of forking and sending
+a pull request.
+
+Before sending a pull request, run the `presubmit` target to format and run
+lint over the code. Fix any issues it indicates. When complete, send the pull
+request.
+
+```shell
+./gradlew presubmit
+```
+
+This target will reformat the code with
+[googleJavaFormat](https://github.com/sherter/google-java-format-gradle-plugin)
+and run lint. The lint report should open in your default browser.
+
+Be sure to address *all* off the errors reported by lint. When finished and you
+run `presubmit` one last time you should see:
+
+> No Issues Found
+> Congratulations!
+
+in your browser.
+
+## Other resources
+
+ * [Mobly multi-device test framework](http://github.com/google/mobly)
+ * [Mobly Snippet Lib](http://github.com/google/mobly-snippet-lib)
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..82e103d
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,111 @@
+buildscript {
+ repositories {
+ jcenter()
+ google()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:4.1.2'
+
+ // NOTE: Do not place your application dependencies here.
+ }
+}
+
+plugins {
+ id "com.github.sherter.google-java-format" version "0.9"
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+ gradle.projectsEvaluated {
+ tasks.withType(JavaCompile) {
+ options.compilerArgs << "-Xlint:all"
+ }
+ }
+}
+
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion 29
+ buildToolsVersion "30.0.2"
+
+ defaultConfig {
+ applicationId "com.google.android.mobly.snippet.bundled"
+ minSdkVersion 15
+ // Set target to 22 to avoid having to deal with runtime permissions.
+ targetSdkVersion 22
+ versionCode 1
+ versionName "0.0.1"
+ setProperty("archivesBaseName", "mobly-bundled-snippets")
+ multiDexEnabled true
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ lintOptions {
+ abortOnError false
+ checkAllWarnings true
+ warningsAsErrors true
+ disable 'HardwareIds','MissingApplicationIcon','GoogleAppIndexingWarning','InvalidPackage','OldTargetApi'
+ }
+}
+
+// Produces a jar of source files. Needed for compliance reasons.
+task sourcesJar(type: Jar) {
+ from android.sourceSets.main.java.srcDirs
+ classifier = 'src'
+}
+
+task javadoc(type: Javadoc) {
+ source = android.sourceSets.main.java.srcDirs
+ classpath += project.files(
+ android.getBootClasspath().join(File.pathSeparator))
+}
+
+artifacts {
+ archives sourcesJar
+}
+
+dependencies {
+ implementation 'androidx.test:runner:1.3.0'
+ implementation 'com.google.android.mobly:mobly-snippet-lib:1.2.0'
+ implementation 'com.google.code.gson:gson:2.8.6'
+ implementation 'com.google.guava:guava:30.1-jre'
+ implementation 'com.google.errorprone:error_prone_annotations:2.5.1'
+
+ testImplementation 'com.google.errorprone:error_prone_annotations:2.5.1'
+ testImplementation 'com.google.guava:guava:30.1-jre'
+ testImplementation 'com.google.truth:truth:1.1.2'
+ testImplementation 'junit:junit:4.13.2'
+}
+
+googleJavaFormat {
+ options style: 'AOSP'
+}
+
+// Open lint's HTML report in your default browser or viewer.
+task openLintReport(type: Exec) {
+ def lint_report = "build/reports/lint-results.html"
+ def cmd = "cat"
+ def platform = System.getProperty('os.name').toLowerCase(Locale.ROOT)
+ if (platform.contains("linux")) {
+ cmd = "xdg-open"
+ } else if (platform.contains("mac os x")) {
+ cmd = "open"
+ } else if (platform.contains("windows")) {
+ cmd = "launch"
+ }
+ commandLine cmd, lint_report
+}
+
+task presubmit {
+ dependsOn { ['googleJavaFormat', 'lint', 'openLintReport'] }
+ doLast {
+ println "Fix any lint issues you see. When it looks good, submit the pull request."
+ }
+}
+
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f8cd742
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx1536M
+android.enableD8.desugaring=true
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..1ba6cc2
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sun Feb 14 02:01:02 CST 2021
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-bin.zip
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..9d82f78
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
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] -&gt; "0102"
+ *
+ * @param bytes The array of byte to convert.
+ * @return a String with the ASCII hex representation.
+ */
+ public static String bytesToHexString(byte[] bytes) {
+ char[] hexChars = new char[bytes.length * 2];
+ for (int j = 0; j < bytes.length; j++) {
+ int v = bytes[j] & 0xFF;
+ hexChars[j * 2] = hexArray[v >>> 4];
+ hexChars[j * 2 + 1] = hexArray[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+}
diff --git a/src/test/java/UtilsTest.java b/src/test/java/UtilsTest.java
new file mode 100644
index 0000000..b9a70d2
--- /dev/null
+++ b/src/test/java/UtilsTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+
+import static com.google.android.mobly.snippet.bundled.utils.Utils.invokeByReflection;
+
+import com.google.android.mobly.snippet.bundled.utils.Utils;
+import com.google.common.truth.Truth;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Assert;
+import org.junit.Test;
+
+/** Tests for {@link com.google.android.mobly.snippet.bundled.utils.Utils} */
+public class UtilsTest {
+ public static final class ReflectionTest_HostClass {
+ public Object returnSame(List<String> arg) {
+ return arg;
+ }
+
+ public Object returnSame(int arg) {
+ return arg;
+ }
+
+ public Object multiArgCall(Object arg1, Object arg2, boolean returnArg1) {
+ if (returnArg1) {
+ return arg1;
+ }
+ return arg2;
+ }
+
+ public boolean returnTrue() {
+ return true;
+ }
+
+ public void throwsException() throws IOException {
+ throw new IOException("Example exception");
+ }
+ }
+
+ @Test
+ public void testInvokeByReflection_Obj() throws Throwable {
+ List<?> sampleList = Collections.singletonList("sampleList");
+ ReflectionTest_HostClass hostClass = new ReflectionTest_HostClass();
+ Object ret = invokeByReflection(hostClass, "returnSame", sampleList);
+ Truth.assertThat(ret).isSameAs(sampleList);
+ }
+
+ @Test
+ public void testInvokeByReflection_Null() throws Throwable {
+ ReflectionTest_HostClass hostClass = new ReflectionTest_HostClass();
+ Object ret = invokeByReflection(hostClass, "returnSame", (Object) null);
+ Truth.assertThat(ret).isNull();
+ }
+
+ @Test
+ public void testInvokeByReflection_NoArg() throws Throwable {
+ ReflectionTest_HostClass hostClass = new ReflectionTest_HostClass();
+ boolean ret = (boolean) invokeByReflection(hostClass, "returnTrue");
+ Truth.assertThat(ret).isTrue();
+ }
+
+ @Test
+ public void testInvokeByReflection_Primitive() throws Throwable {
+ ReflectionTest_HostClass hostClass = new ReflectionTest_HostClass();
+ Object ret = invokeByReflection(hostClass, "returnSame", 5);
+ Truth.assertThat(ret).isEqualTo(5);
+ }
+
+ @Test
+ public void testInvokeByReflection_MultiArg() throws Throwable {
+ ReflectionTest_HostClass hostClass = new ReflectionTest_HostClass();
+ Object arg1 = new Object();
+ Object arg2 = new Object();
+ Object ret =
+ invokeByReflection(hostClass, "multiArgCall", arg1, arg2, true /* returnArg1 */);
+ Truth.assertThat(ret).isSameAs(arg1);
+ ret =
+ Utils.invokeByReflection(
+ hostClass, "multiArgCall", arg1, arg2, false /* returnArg1 */);
+ Truth.assertThat(ret).isSameAs(arg2);
+ }
+
+ @Test
+ public void testInvokeByReflection_NoMatch() throws Throwable {
+ ReflectionTest_HostClass hostClass = new ReflectionTest_HostClass();
+ Truth.assertThat(List.class.isAssignableFrom(Object.class)).isFalse();
+ try {
+ invokeByReflection(hostClass, "returnSame", new Object());
+ Assert.fail();
+ } catch (NoSuchMethodException e) {
+ Truth.assertThat(e.getMessage())
+ .contains("UtilsTest$ReflectionTest_HostClass#returnSame(Object)");
+ }
+ }
+
+ @Test
+ public void testInvokeByReflection_UnwrapException() throws Throwable {
+ ReflectionTest_HostClass hostClass = new ReflectionTest_HostClass();
+ try {
+ invokeByReflection(hostClass, "throwsException");
+ Assert.fail();
+ } catch (IOException e) {
+ Truth.assertThat(e.getMessage()).isEqualTo("Example exception");
+ }
+ }
+}