blob: 6665cfa03ea870560318b15a78ebab602a89dfd2 [file] [log] [blame]
/*
* Copyright 2018 The Android Open Source Project
*
* 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.android.tradefed.testtype.pandora;
import com.android.tradefed.config.ConfigurationException;
import com.android.tradefed.config.Option;
import com.android.tradefed.config.Option.Importance;
import com.android.tradefed.config.OptionCopier;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.invoker.ExecutionFiles.FilesKey;
import com.android.tradefed.invoker.TestInformation;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.result.ITestInvocationListener;
import com.android.tradefed.result.TestDescription;
import com.android.tradefed.testtype.IRemoteTest;
import com.android.tradefed.testtype.IShardableTest;
import com.android.tradefed.testtype.ITestFilterReceiver;
import com.android.tradefed.util.CommandResult;
import com.android.tradefed.util.CommandStatus;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.IRunUtil;
import com.android.tradefed.util.PythonVirtualenvHelper;
import com.android.tradefed.util.RunUtil;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors;
/**
* Run PTS-bot tests. PTS-bot is a complete automation of the Bluetooth Profile Tuning Suite, which
* is the testing tool provided by the Bluetooth standard to run Bluetooth Host certification tests
* (see
* https://www.bluetooth.com/develop-with-bluetooth/qualification-listing/qualification-test-tools/profile-tuning-suite/).
*/
public class PtsBotTest implements IRemoteTest, ITestFilterReceiver, IShardableTest {
private static final String TAG = "PandoraPtsBot";
// This is the fixed port of the Pandora gRPC server running on the DUT.
private static final int PANDORA_SERVER_PORT = 8999;
// This is the fixed port where pts-bot is sending HCI traffic for physical
// tests. This can typically redirect to a Bluetooth dongle on the host
// (i.e. the machine running pts-bot) which will connect to the DUT.
private static final int HCI_PROXY_PORT = 1234;
// These are Rootcanal CF Host fixed ports as specified at Cuttlefish
// startup, in assemble_cvd/flags.cc.
private static final int HCI_ROOTCANAL_PORT_CF = 7300;
private static final int CONTROL_ROOTCANAL_PORT_CF = 7500;
private static final int ROOTCANAL_VSOCK_CID = 2;
// This is the vsock port where the modem simulator is exposed in the
// Cuttlefish guest. It is specified in
// cs/android/device/google/cuttlefish/host/commands/assemble_cvd/flags.cc;l=1224
private static final int MODEM_SIMULATOR_VSOCK_CID = 2;
private static final int MODEM_SIMULATOR_VSOCK_PORT = 9600;
// These are the host (i.e. the machine running pts-bot) ports on which we
// forward Pandora Server, Rootcanal HCI and Control ports of the DUT, and
// the modem_simulator port. These ports are not fixed because when
// sharding, multiple hosts (or a single host) can share the same physical
// resources and are thus determined dynamically.
private int hostPandoraServerPort;
private int hostHciRootcanalPort;
private int hostControlRootcanalPort;
private int hostModemSimulatorPort;
// PTS inactivity timeout in seconds.
// Must be above 60s to avoid conflict with PTS timeout for triggering
// disconnections. This notably happens on Android which closes GATT but
// does not explicitly close the underlying BLE ACL link.
private static final int PTS_INACTIVITY_TIMEOUT = 90;
private static final String A2DP_SNK_PROPERTY = "bluetooth.profile.a2dp.sink.enabled";
private static final String A2DP_SRC_PROPERTY = "bluetooth.profile.a2dp.source.enabled";
private static final String HFP_HF_PROPERTY = "bluetooth.profile.hfp.hf.enabled";
private static final String HFP_AG_PROPERTY = "bluetooth.profile.hfp.ag.enabled";
private IRunUtil mRunUtil = new RunUtil();
@Option(name = "pts-bot-path", description = "pts-bot binary path.")
private File ptsBotPath = new File("pts-bot");
@Option(name = "pts-setup-path", description = "Bluetooth SIG pts setup path.")
private File ptsSetupPath = null;
@Option(
name = "create-bin-temp-dir",
description =
"Create a temporary directory to store pts-bot binaries and avoid conflicts"
+ " when multiple runners are on the same machine")
private boolean createBinTempDir = false;
private File binTempDir = null;
@Option(name = "python-home", description = "PYTHONHOME value to use while running pts-bot.")
private File pythonHome = null;
@Option(name = "mmi2grpc", description = "mmi2grpc python module path.")
private File mmi2grpc = null;
@Option(
name = "tests-config-file",
description = "Tests config file.",
importance = Importance.ALWAYS)
private File testsConfigFile = null;
@Option(name = "profile", description = "Profile to be tested.", importance = Importance.ALWAYS)
private SortedSet<String> profiles = new TreeSet<>();
@Option(
name = "physical",
description = "Run PTS-bot with a physical Bluetooth communication.",
importance = Importance.ALWAYS)
private boolean physical = false;
@Option(
name = "max-flaky-tests",
description = "Maximum number of flaky tests for the entire run.")
private int maxFlakyTests = 0;
private int flakyCount = 0;
@Option(
name = "max-retries-per-test",
description = "Maximum number of retries for a flaky test.")
private int maxRetriesPerTest = 0;
private final Set<String> includeFilters = new LinkedHashSet<>();
private final Set<String> excludeFilters = new LinkedHashSet<>();
@Override
public void addIncludeFilter(String filter) {
includeFilters.add(filter);
}
@Override
public void addAllIncludeFilters(Set<String> filters) {
includeFilters.addAll(filters);
}
@Override
public void addExcludeFilter(String filter) {
excludeFilters.add(filter);
}
@Override
public void addAllExcludeFilters(Set<String> filters) {
excludeFilters.addAll(filters);
}
@Override
public Set<String> getIncludeFilters() {
return includeFilters;
}
@Override
public Set<String> getExcludeFilters() {
return excludeFilters;
}
@Override
public void clearIncludeFilters() {
includeFilters.clear();
}
@Override
public void clearExcludeFilters() {
excludeFilters.clear();
}
private int shardIndex = 0;
private int totalShards = 1;
@Override
public Collection<IRemoteTest> split(int shardCountHint) {
if (physical || shardCountHint <= 1) {
// We cannot share the Bluetooth dongle across multiple tests in
// parallel, and there's no point "sharding" with just one shard.
// For now, sharding only works on ATP and not locally.
return null;
}
Collection<IRemoteTest> shards = new ArrayList<>(shardCountHint);
// Split tests between shards.
int startIndex = 0;
for (int i = 0; i < shardCountHint; i++) {
PtsBotTest shard = new PtsBotTest();
shard.addAllIncludeFilters(getIncludeFilters());
shard.addAllExcludeFilters(getExcludeFilters());
// Copy all options.
try {
OptionCopier.copyOptions(this, shard);
} catch (ConfigurationException e) {
CLog.e("Failed to copy options: %s", e.getMessage());
}
shard.shardIndex = i;
shard.totalShards = shardCountHint;
shards.add(shard);
}
return shards;
}
@Override
public void run(TestInformation testInfo, ITestInvocationListener listener)
throws DeviceNotAvailableException {
if (createBinTempDir) {
try {
// Create a temp directory with a randomized name (fixed prefix).
binTempDir = FileUtil.createTempDir("pts-bot");
} catch (IOException e) {
throw new RuntimeException("Not able to create temp directory");
}
}
// If tests config file cannot be found using full path, search in
// dependencies.
if (!testsConfigFile.exists()) {
try {
testsConfigFile = testInfo.getDependencyFile(testsConfigFile.getName(), false);
} catch (FileNotFoundException e) {
throw new RuntimeException("Tests config file does not exist");
}
}
// If mmi2grpc cannot be found using full path, search in
// dependencies.
// As mmi2grpc is a folder, we cannot use getDependencyFile.
if (!mmi2grpc.exists()) {
try {
File testsDir = testInfo.executionFiles().get(FilesKey.TESTS_DIRECTORY);
mmi2grpc = FileUtil.findDirectory(mmi2grpc.getName(), testsDir);
} catch (IOException e) {
throw new RuntimeException(e);
}
if (mmi2grpc == null) {
throw new RuntimeException("mmi2grpc folder does not exist");
}
}
// Test resources files are not executable.
ptsBotPath.setExecutable(true);
displayPtsBotVersion();
CLog.i("Tests config file: %s", testsConfigFile.getPath());
CLog.i("Profiles to be tested: %s", profiles);
ITestDevice testDevice = testInfo.getDevice();
// Forward allocated host Pandora Server port to actual DUT Pandora
// Server ports.
hostPandoraServerPort =
Integer.parseInt(adbForwardPort(testDevice, 0, PANDORA_SERVER_PORT).trim());
CLog.i("PTS HCI port: %s", getHciPort());
if (!physical) {
// Forward allocated host Rootcanal ports to DUT Rootcanal ports.
hostHciRootcanalPort =
Integer.parseInt(
adbForwardVsockPort(
testDevice,
0,
ROOTCANAL_VSOCK_CID,
HCI_ROOTCANAL_PORT_CF)
.trim());
hostControlRootcanalPort =
Integer.parseInt(
adbForwardVsockPort(
testDevice,
0,
ROOTCANAL_VSOCK_CID,
CONTROL_ROOTCANAL_PORT_CF)
.trim());
// forward host port to DUT modem_simulator vsock
hostModemSimulatorPort =
Integer.parseInt(
adbForwardVsockPort(
testDevice,
0,
MODEM_SIMULATOR_VSOCK_CID,
MODEM_SIMULATOR_VSOCK_PORT)
.trim());
}
// List all applicable tests in a sorted fashion.
String[] allFilteredTests = getAllFilteredTests(testInfo).toArray(new String[0]);
// Split tests across shards.
int chunkSize = allFilteredTests.length / totalShards;
if (allFilteredTests.length % totalShards > 0) chunkSize++;
int startIndex = shardIndex * chunkSize;
int endIndex =
(totalShards == 1 || shardIndex == totalShards - 1)
? allFilteredTests.length
: (shardIndex + 1) * chunkSize;
String[] tests = Arrays.copyOfRange(allFilteredTests, startIndex, endIndex);
// Execute tests.
runPtsBotTests(tests, testInfo, listener);
// Remove forwarded ports.
adbForwardRemovePort(testDevice, hostPandoraServerPort);
if (!physical) {
adbForwardRemovePort(testDevice, hostHciRootcanalPort);
adbForwardRemovePort(testDevice, hostControlRootcanalPort);
adbForwardRemovePort(testDevice, hostModemSimulatorPort);
}
// Clean up temp directory.
if (binTempDir != null) FileUtil.recursiveDelete(binTempDir);
}
private void displayPtsBotVersion() {
CommandResult c;
c = mRunUtil.runTimedCmd(5000, "which", ptsBotPath.getPath());
if (c.getStatus() != CommandStatus.SUCCESS) {
CLog.e("Failed to get pts-bot path");
CLog.e(
"Status: %s\nStdout: %s\nStderr: %s",
c.getStatus(), c.getStdout(), c.getStderr());
throw new RuntimeException("Failed to get pts-bot path. Error:\n" + c.getStderr());
}
String ptsBotAbsolutePath = c.getStdout().trim();
c = mRunUtil.runTimedCmd(5000, ptsBotAbsolutePath, "--version");
if (c.getStatus() != CommandStatus.SUCCESS) {
CLog.e("Failed to get pts-bot version");
CLog.e(
"Status: %s\nStdout: %s\nStderr: %s",
c.getStatus(), c.getStdout(), c.getStderr());
throw new RuntimeException("Failed to get pts-bot version. Error:\n" + c.getStderr());
}
CLog.d("pts-bot version: %s", c.getStdout().trim());
}
/**
* The port that the PTS bot writes HCI commands to. When running virtual tests, it is the
* Rootcanal HCI port exposed on the host (i.e. the machine running PTS-bot), which corresponds
* to the port on which the DUT Rootcanal port is forwarded to. For physical tests, this port is
* fixed, which means that sharding cannot be supported.
*/
private int getHciPort() {
return physical ? HCI_PROXY_PORT : hostHciRootcanalPort;
}
private SortedSet<String> getAllFilteredTests(TestInformation testInfo) {
SortedSet<String> allFilteredTests = new TreeSet<>();
for (String profile : profiles) {
// Check whether we should skip the entire profile.
if (shouldSkipProfileOrTest(profile)) continue;
allFilteredTests.addAll(
Arrays.stream(listPtsBotTestsForProfile(profile, testInfo))
.filter(testName -> !shouldSkipProfileOrTest(testName))
.collect(Collectors.toList()));
}
return allFilteredTests;
}
private boolean shouldSkipProfileOrTest(String profileName) {
// Note that while the logic is described in terms of profiles, we can think
// of tests as "leaf" profiles, so the same reasoning applies
for (var filter : excludeFilters) {
if (profileName.startsWith(filter)) {
// If we have an exclude filter X/Y and profile X/Y/Z, our
// profile can never be run, so skip
return true;
}
// If we have an exclude filter X/Y/Z and profile X/Y, our profile
// may still be run, so continue
}
for (var filter : includeFilters) {
if (profileName.startsWith(filter)) {
// If we have an include filter X/Y and profile X/Y/Z, our
// profile will always be run (fully)
return false;
}
if (filter.startsWith(profileName)) {
// If we have an include filter X/Y/Z and profile X/Y, our
// profile may be partially run, so include
return false;
}
}
// No include filters imply running all tests / all profiles
if (includeFilters.isEmpty()) {
return false;
}
// If we aren't explicitly included in any include filter, then skip
return true;
}
private String[] listPtsBotTestsForProfile(String profile, TestInformation testInfo) {
try {
ProcessBuilder processBuilder = ptsBot(testInfo, "--list", profile);
CLog.i("Running command: %s", String.join(" ", processBuilder.command()));
Process process = processBuilder.start();
BufferedReader stdInput =
new BufferedReader(new InputStreamReader(process.getInputStream()));
BufferedReader stdError =
new BufferedReader(new InputStreamReader(process.getErrorStream()));
stdError.lines().forEach(line -> CLog.e(line));
stdError.close();
String line =
stdInput.lines().filter(l -> l.startsWith("Tests:")).findFirst().orElse(null);
stdInput.close();
if (line != null) {
String testsStr = line.substring(line.indexOf("[") + 1, line.indexOf("]"));
if (!testsStr.equals("")) {
return testsStr.replaceAll("\"", "").split(", ");
} else {
return new String[0];
}
}
} catch (IOException e) {
CLog.e(e);
throw new RuntimeException("Cannot run pts-bot, make sure it is properly installed");
}
throw new RuntimeException(String.format("Cannot list tests for %s", profile));
}
private void runPtsBotTests(
String[] tests, TestInformation testInfo, ITestInvocationListener listener) {
if (tests.length == 0) {
CLog.w("No tests to execute");
return;
}
// Grouping tests by profile.
for (String profile : profiles) {
String[] profileTests =
Arrays.stream(tests)
.filter(testName -> testName.startsWith(profile))
.toArray(String[]::new);
if (profileTests.length > 0) {
Map<String, String> runMetrics = new HashMap<>();
listener.testRunStarted(profile, profileTests.length);
long startTimestamp = System.currentTimeMillis();
for (String testName : profileTests) {
toggleA2dpSinkIfNeeded(testInfo.getDevice(), testName);
toggleHfpHfIfNeeded(testInfo.getDevice(), testName);
runPtsBotTest(profile, testName, testInfo, listener);
}
long endTimestamp = System.currentTimeMillis();
listener.testRunEnded(endTimestamp - startTimestamp, runMetrics);
} else {
CLog.i("No tests applicable for %s", profile);
}
}
}
private void toggleA2dpSinkIfNeeded(ITestDevice testDevice, String testName) {
CLog.i("toggleA2dpSinkIfNeeded: " + testName);
if (testName.startsWith("A2DP/SNK")
|| testName.startsWith("AVCTP/CT")
|| testName.startsWith("AVDTP/SNK")
|| (testName.startsWith("AVRCP/CT") && !testName.startsWith("AVRCP/CT/VLH"))
|| testName.startsWith("AVRCP/TG/VLH")) {
setProperty(testDevice, A2DP_SNK_PROPERTY, true);
setProperty(testDevice, A2DP_SRC_PROPERTY, false);
} else if (!getProperty(testDevice, A2DP_SRC_PROPERTY).equals("true")) {
setProperty(testDevice, A2DP_SNK_PROPERTY, false);
setProperty(testDevice, A2DP_SRC_PROPERTY, true);
}
}
private void toggleHfpHfIfNeeded(ITestDevice testDevice, String testName) {
CLog.i("toggleHfpHfIfNeeded: " + testName);
if (testName.startsWith("HFP/HF")) {
setProperty(testDevice, HFP_HF_PROPERTY, true);
setProperty(testDevice, HFP_AG_PROPERTY, false);
} else if (!getProperty(testDevice, HFP_HF_PROPERTY).equals("true")) {
setProperty(testDevice, HFP_HF_PROPERTY, false);
setProperty(testDevice, HFP_AG_PROPERTY, true);
}
}
private void setProperty(ITestDevice testDevice, String property, boolean enable) {
CLog.i("setProperty: " + property);
try {
String cmd = String.format("setprop %s %s", property, enable);
CommandResult result = testDevice.executeShellV2Command(cmd);
if (result.getExitCode() != 0) {
CLog.e("Failed to set property: " + property + ": " + result.getStderr());
}
} catch (DeviceNotAvailableException e) {
CLog.e("setProperty error: " + e);
}
}
private String getProperty(ITestDevice testDevice, String property) {
CLog.i("getProperty: " + property);
try {
String cmd = String.format("getprop %s", property);
CommandResult result = testDevice.executeShellV2Command(cmd);
if (result.getExitCode() != 0) {
CLog.e("Failed to get property: " + property + ": " + result.getStderr());
} else {
return result.getStdout();
}
} catch (DeviceNotAvailableException e) {
CLog.e("getProperty error: " + e);
}
return "";
}
private boolean runPtsBotTest(
String profile,
String testName,
TestInformation testInfo,
ITestInvocationListener listener) {
TestDescription testDescription = new TestDescription(profile, testName);
listener.testStarted(testDescription);
CLog.i(testName);
androidLogInfo(testInfo.getDevice(), "Test Started: " + testName);
boolean success = false;
boolean inconclusive = false;
int retryCount = 0;
while (true) {
try {
// The last three arguments are the Pandora gRPC server port,
// the Rootcanal control port which pts-bot provides to
// mmi2grpc, and the modem simulator port that is forwarded from
// the guest
ProcessBuilder processBuilder =
ptsBot(
testInfo,
testName,
String.valueOf(hostPandoraServerPort),
String.valueOf(hostControlRootcanalPort),
String.valueOf(hostModemSimulatorPort));
CLog.i("Running command: %s", String.join(" ", processBuilder.command()));
Process process = processBuilder.start();
// Note: there is no need to implement a timeout here since it
// is handled in pts-bot.
BufferedReader stdInput =
new BufferedReader(new InputStreamReader(process.getInputStream()));
BufferedReader stdError =
new BufferedReader(new InputStreamReader(process.getErrorStream()));
CompletableFuture<Void> future =
CompletableFuture.runAsync(
() -> {
stdError.lines().forEach(line -> CLog.e(line));
try {
stdError.close();
} catch (IOException e) {
throw new CompletionException(e);
}
});
Optional<String> lastLine =
stdInput.lines().peek(line -> CLog.i(line)).reduce((last, value) -> value);
// Last line is providing success/inconclusive information.
if (lastLine.isPresent()) {
try {
success =
Integer.parseInt(lastLine.get().split(", ")[1].substring(0, 1))
== 1;
inconclusive =
Integer.parseInt(lastLine.get().split(", ")[3].substring(0, 1))
== 1;
} catch (Exception e) {
CLog.e("Failed to parse status");
}
}
stdInput.close();
future.join();
} catch (Exception e) {
CLog.e(e);
CLog.e("Cannot run pts-bot, make sure it is properly installed");
}
if (success) break;
// Retry in case of inconclusive or failure.
retryCount++;
// At the first retry, increment flaky tests count.
if (retryCount == 1) flakyCount++;
if (flakyCount <= maxFlakyTests && retryCount <= maxRetriesPerTest) {
androidLogWarning(
testInfo.getDevice(),
String.format(
"Test %s: %s, retrying [count=%s]",
inconclusive ? "Inconclusive" : "Failed", testName, retryCount));
} else {
break;
}
}
if (success) {
androidLogInfo(testInfo.getDevice(), "Test Ended [Success]: " + testName);
} else {
androidLogError(testInfo.getDevice(), "Test Ended [Failed]: " + testName);
listener.testFailed(
testDescription,
String.format(
"Test case %s failed, please route bugs to"
+ " android-bluetooth@google.com\n"
+ "Refer to host_log files to find the test logs",
testName));
}
listener.testEnded(testDescription, Collections.emptyMap());
return success;
}
private ProcessBuilder ptsBot(TestInformation testInfo, String... args) {
List<String> command = new ArrayList<>();
command.add(ptsBotPath.getPath());
command.add("--config");
command.add(testsConfigFile.getPath());
command.add("--hci");
command.add(String.valueOf(getHciPort()));
command.add("--inactivity-timeout");
command.add(String.valueOf(PTS_INACTIVITY_TIMEOUT));
if (ptsSetupPath != null) {
command.add("--pts-setup");
command.add(ptsSetupPath.getPath());
}
command.addAll(Arrays.asList(args));
ProcessBuilder builder = new ProcessBuilder(command);
if (binTempDir != null) builder.environment().put("XDG_CACHE_HOME", binTempDir.getPath());
if (pythonHome != null) builder.environment().put("PYTHONHOME", pythonHome.getPath());
String pythonPath = mmi2grpc.getPath();
File venvDir = testInfo.getBuildInfo().getFile(PythonVirtualenvHelper.VIRTUAL_ENV);
if (venvDir != null) {
String packagePath =
PythonVirtualenvHelper.getPackageInstallLocation(
mRunUtil, venvDir.getAbsolutePath());
pythonPath += ":" + packagePath;
}
// Add mmi2grpc python module path to process builder environment.
builder.environment().put("PYTHONPATH", pythonPath);
return builder;
}
private String adbForwardPort(ITestDevice testDevice, int hostPort, int dutPort)
throws DeviceNotAvailableException {
return testDevice.executeAdbCommand(
"forward", String.format("tcp:%s", hostPort), String.format("tcp:%s", dutPort));
}
private String adbForwardVsockPort(
ITestDevice testDevice, int hostPort, int dutCid, int dutPort)
throws DeviceNotAvailableException {
return testDevice.executeAdbCommand(
"forward",
String.format("tcp:%s", hostPort),
String.format("vsock:%s:%s", dutCid, dutPort));
}
private void adbForwardRemovePort(ITestDevice testDevice, int hostPort)
throws DeviceNotAvailableException {
testDevice.executeAdbCommand("forward", "--remove", String.format("tcp:%s", hostPort));
}
private void androidLog(ITestDevice testDevice, String priority, String content) {
try {
String timeStamp = new SimpleDateFormat("HH:mm:ss.SSS").format(new Date());
String command =
String.format(
"log -t %s -p %s '%s (%s host time)'",
TAG, priority, content, timeStamp);
CommandResult result = testDevice.executeShellV2Command(command);
if (result.getStatus() != CommandStatus.SUCCESS) {
CLog.w(
String.format(
"Command '%s' exited with status %s (code %s)",
command, result.getStatus(), result.getExitCode()));
}
} catch (DeviceNotAvailableException e) {
CLog.w("Failed to send android log, device not available: " + e);
}
}
private void androidLogInfo(ITestDevice testDevice, String content) {
androidLog(testDevice, "i", content);
}
private void androidLogWarning(ITestDevice testDevice, String content) {
androidLog(testDevice, "w", content);
}
private void androidLogError(ITestDevice testDevice, String content) {
androidLog(testDevice, "e", content);
}
}