diff options
author | frankfeng <frankfeng@google.com> | 2021-12-23 22:57:00 +0000 |
---|---|---|
committer | Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com> | 2021-12-23 22:57:00 +0000 |
commit | 81208a869d94d872cf1304bedeffc7032056d485 (patch) | |
tree | 71f65f7f55b0604e60173d7004994baa85e97c35 | |
parent | bcbece2f1ebff711bbb8358a4dc709420d93e3a1 (diff) | |
parent | 36217304e1582ed36c7a365d9a94c9f1de2074cc (diff) | |
download | mobly-bundled-snippets-81208a869d94d872cf1304bedeffc7032056d485.tar.gz |
Merge remote-tracking branch 'aosp/upstream-master' into mymerge am: b3028abea8 am: 233a984639 am: 36217304e1
Original change: https://android-review.googlesource.com/c/platform/external/mobly-bundled-snippets/+/1932819
Change-Id: I2e4bd451aa6bc266b538d18972ed67020b345b57
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/ @@ -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 @@ -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 Binary files differnew file mode 100644 index 0000000..13372ae --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.jar 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 @@ -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] -> "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"); + } + } +} |