/* * Copyright 2012-2014 eBay Software Foundation and selendroid committers. * * 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 io.selendroid.standalone.android.impl; import com.android.ddmlib.AdbCommandRejectedException; import com.android.ddmlib.IDevice; import com.android.ddmlib.RawImage; import com.android.ddmlib.TimeoutException; import com.google.common.base.Splitter; import com.google.common.collect.Lists; import com.google.common.collect.ObjectArrays; import io.selendroid.common.SelendroidCapabilities; import io.selendroid.server.common.exceptions.SelendroidException; import io.selendroid.server.common.model.ExternalStorageFile; import io.selendroid.server.common.utils.SelendroidArguments; import io.selendroid.standalone.android.AndroidApp; import io.selendroid.standalone.android.AndroidDevice; import io.selendroid.standalone.android.AndroidSdk; import io.selendroid.standalone.exceptions.AndroidDeviceException; import io.selendroid.standalone.exceptions.AndroidSdkException; import io.selendroid.standalone.exceptions.ShellCommandException; import io.selendroid.standalone.io.ShellCommand; import org.apache.commons.exec.*; import org.apache.commons.io.IOUtils; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.impl.client.HttpClientBuilder; import org.openqa.selenium.Dimension; import org.openqa.selenium.logging.LogEntry; import org.json.JSONException; import org.json.JSONObject; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Iterator; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; public abstract class AbstractDevice implements AndroidDevice { private static final Logger log = Logger.getLogger(AbstractDevice.class.getName()); public static final String WD_STATUS_ENDPOINT = "http://localhost:8080/wd/hub/status"; public static final int MAX_ADB_COMMAND_LENGTH = 1024; protected String serial = null; protected String model = null; protected String apiTargetType = "android"; protected Integer port = null; protected IDevice device; private ByteArrayOutputStream logoutput; private ExecuteWatchdog logcatWatchdog; private static final Integer COMMAND_TIMEOUT = 20000; private boolean loggingEnabled = true; /** * Constructor meant to be used with Android Emulators because a reference to the {@link IDevice} * will become available if the emulator will be started. Please make sure that #setIDevice is * called on the emulator. * * @param serial */ public AbstractDevice(String serial) { this.serial = serial; } /** * Constructor mean to be used with Android Hardware devices because a reference to the * {@link IDevice} will be available immediately after they are connected. * * @param device */ public AbstractDevice(IDevice device) { this.device = device; this.serial = device.getSerialNumber(); } protected AbstractDevice() {} protected boolean isSerialConfigured() { return serial != null && !serial.isEmpty(); } public void setVerbose() { log.setLevel(Level.FINEST); } @Override public boolean isDeviceReady() { CommandLine command = adbCommand("shell", "getprop init.svc.bootanim"); String bootAnimDisplayed = null; try { bootAnimDisplayed = ShellCommand.exec(command); } catch (ShellCommandException e) { log.log(Level.INFO, "Could not get property init.svc.bootanim", e); } return bootAnimDisplayed != null && bootAnimDisplayed.contains("stopped"); } @Override public boolean isInstalled(String appBasePackage) throws AndroidSdkException { CommandLine command = adbCommand("shell", "pm", "list", "packages"); command.addArgument(appBasePackage, false); String result = null; try { result = ShellCommand.exec(command); } catch (ShellCommandException e) {} return result != null && result.contains("package:" + appBasePackage); } @Override public boolean isInstalled(AndroidApp app) throws AndroidSdkException { return isInstalled(app.getBasePackage()); } @Override public void install(AndroidApp app) throws AndroidSdkException { // Uninstall if already installed if (isInstalled(app)) { uninstall(app); } // -r: replace existing application // -d: allow version code downgrade CommandLine command = adbCommand("install", "-r", "-d", app.getAbsolutePath()); String out = executeCommandQuietly(command, COMMAND_TIMEOUT * 6); try { // give it a second to recover from the install Thread.sleep(1000); } catch (InterruptedException ie) { throw new RuntimeException(ie); } if (!out.contains("Success")) { throw new AndroidSdkException("APK installation failed. Output:\n" + out); } } public boolean start(AndroidApp app) throws AndroidSdkException { if (!isInstalled(app)) { install(app); } String mainActivity = app.getMainActivity().replace(app.getBasePackage(), ""); CommandLine command = adbCommand("shell", "am", "start", "-a", "android.intent.action.MAIN", "-n", app.getBasePackage() + "/" + mainActivity); String out = executeCommandQuietly(command); try { // give it a second to recover from the activity start Thread.sleep(1000); } catch (InterruptedException ie) { throw new RuntimeException(ie); } return out.contains("Starting: Intent"); } protected String executeCommandQuietly(CommandLine command) { return executeCommandQuietly(command, COMMAND_TIMEOUT); } protected String executeCommandQuietly(CommandLine command, long timeout) { try { return ShellCommand.exec(command, timeout); } catch (ShellCommandException e) { String logMessage = String.format("Could not execute command: %s", command); log.log(Level.WARNING, logMessage, e); return ""; } } @Override public void uninstall(AndroidApp app) throws AndroidSdkException { CommandLine command = adbCommand("uninstall", app.getBasePackage()); executeCommandQuietly(command); try { // give it a second to recover from the uninstall Thread.sleep(1000); } catch (InterruptedException ie) { throw new RuntimeException(ie); } } @Override public void clearUserData(AndroidApp app) throws AndroidSdkException { CommandLine command = adbCommand("shell", "pm", "clear", app.getBasePackage()); executeCommandQuietly(command); } @Override public void kill(AndroidApp aut) throws AndroidDeviceException, AndroidSdkException { try { CommandLine command = adbCommand("shell", "am", "force-stop", aut.getBasePackage()); executeCommandQuietly(command); } finally { killProcesses(aut.getBasePackage()); freeSelendroidPort(); } if (logcatWatchdog != null && logcatWatchdog.isWatching()) { logcatWatchdog.destroyProcess(); logcatWatchdog = null; } } private void killProcesses(String packageName) { CommandLine command = adbCommand("shell", "ps"); String processes = ""; try { processes = ShellCommand.exec(command); } catch (ShellCommandException e) { String logMessage = String.format("Could not execute command: %s", command); log.log(Level.WARNING, logMessage, e); } for (String process: processes.split("\\r\\n|\\r|\\n")) { if (process.endsWith(packageName)) { String pid = process.split("\\s+")[1]; command = adbCommand("shell", "run-as", packageName, "kill", pid); executeCommandQuietly(command); } } } private void freeSelendroidPort() { if (this.port == null) { // Not set return; } CommandLine command = adbCommand("forward", "--remove", "tcp:" + this.port); try { ShellCommand.exec(command, 20000); } catch (ShellCommandException e) { log.log(Level.WARNING, "Could not free Selendroid port", e); } } @Override public void startSelendroid(AndroidApp aut, int port, SelendroidCapabilities capabilities) throws AndroidSdkException { this.port = port; List<String> argList = Lists.newArrayList( "-e", SelendroidArguments.MAIN_ACTIVITY, aut.getMainActivity(), "-e", SelendroidArguments.SERVER_PORT, Integer.toString(port)); if (capabilities.getUseJUnitBootstrap()) { argList.addAll(Lists.newArrayList( "-e", "timeout_msec", "0", // No timeout for the looper thread "-e", "disableAnalytics", "true")); // AndroidJUnitRunner sends things to Google Analytics by default } if (capabilities.getSelendroidExtensions() != null) { argList.addAll(Lists.newArrayList("-e", SelendroidArguments.LOAD_EXTENSIONS, "true")); if (capabilities.getBootstrapClassNames() != null) { argList.addAll(Lists.newArrayList("-e", SelendroidArguments.BOOTSTRAP, capabilities.getBootstrapClassNames())); } } if (capabilities.hasExtraAUTArgs()) { try { JSONObject extraArgs = capabilities.getExtraAUTArgs(); Iterator<String> keys = extraArgs.keys(); while (keys.hasNext()) { final String key = keys.next(); argList.addAll(Lists.newArrayList("-e", key, (String) extraArgs.get(key))); } } catch (JSONException e) { log.log(Level.WARNING, "Failed to read extra AUT args", e); } } if (capabilities.getUseJUnitBootstrap()) { argList.add("io.selendroid." + aut.getBasePackage() + "/io.selendroid.server.JUnitRunnerInstrumentation"); } else { argList.add("io.selendroid." + aut.getBasePackage() + "/io.selendroid.server.SelendroidInstrumentation"); } String[] args = argList.toArray(new String[argList.size()]); if (capabilities.getUseJUnitBootstrap()) { runInstrumentCommandWithJUnitBootstrap(args); } else { runInstrumentCommand(args); } forwardSelendroidPort(port); if(isLoggingEnabled()) { startLogging(); } } private void runInstrumentCommandWithJUnitBootstrap(String[] args) { CommandLine command = adbCommand(ObjectArrays.concat(new String[]{"shell", "am", "instrument", "-w"}, args, String.class)); final ShellCommand.PrintingLogOutputStream os = new ShellCommand.PrintingLogOutputStream(); try { ShellCommand.execAsync(null, command, new PumpStreamHandler(os), new ExecuteResultHandler() { @Override public void onProcessComplete(int exitValue) { String output = os.getOutput(); if (os.getOutput().contains("FAILED") || os.getOutput().contains("FAILURE") || os.getOutput().contains("crashed")) { throw new SelendroidException(output); } } @Override public void onProcessFailed(ExecuteException e) { throw new SelendroidException(e); } }); } catch(Exception e) { throw new SelendroidException(e); } } private void runInstrumentCommand(String[] args) { CommandLine command = adbCommand(ObjectArrays.concat(new String[]{"shell", "am", "instrument"}, args, String.class)); String result = executeCommandQuietly(command); if (result.contains("FAILED")) { String genericMessage = "Could not start the app under test using instrumentation."; String detailedMessage; try { // Try again, waiting for instrumentation to finish. This way we'll get more error output. String[] instrumentCmd = ObjectArrays.concat(new String[]{"shell", "am", "instrument", "-w"}, args, String.class); CommandLine getDetailedErrorCommand = adbCommand(instrumentCmd); String detailedResult = executeCommandQuietly(getDetailedErrorCommand); if (detailedResult.contains("package")) { detailedMessage = genericMessage + " Is the correct app under test installed? Read the details below:\n" + detailedResult; } else { detailedMessage = genericMessage + " Read the details below:\n" + detailedResult; } } catch (Exception e) { // Can't get detailed results throw new SelendroidException(genericMessage, e); } throw new SelendroidException(detailedMessage); } } public void forwardPort(int local, int remote) { CommandLine command = adbCommand("forward", "tcp:" + local, "tcp:" + remote); try { ShellCommand.exec(command, 20000); } catch (ShellCommandException forwardException) { String debugForwardList; try { debugForwardList = ShellCommand.exec(adbCommand("forward", "--list"), 10000); } catch (ShellCommandException listException) { debugForwardList = "Could not get list of forwarded ports."; } throw new SelendroidException( "Could not forward port: " + command + "\nList of forwarded ports:\n" + debugForwardList, forwardException); } } private void forwardSelendroidPort(int port) { forwardPort(port, port); } @Override public boolean isSelendroidRunning() { HttpClient httpClient = HttpClientBuilder.create().build(); String url = WD_STATUS_ENDPOINT.replace("8080", String.valueOf(port)); log.info("Checking if the Selendroid server is running: " + url); HttpRequestBase request = new HttpGet(url); HttpResponse response; try { response = httpClient.execute(request); } catch (Exception e) { log.info("Can't connect to Selendroid server, assuming it is not running."); return false; } int statusCode = response.getStatusLine().getStatusCode(); log.info("Got response status code: " + statusCode); String responseValue; try { responseValue = IOUtils.toString(response.getEntity().getContent()); log.info("Got response value: " + responseValue); } catch (Exception e) { log.log(Level.INFO, "Error reading response from selendroid-server", e); log.info("Assuming server has not started"); return false; } return statusCode == 200 && responseValue.contains("selendroid"); } @Override public int getSelendroidsPort() { return port; } @Override public List<LogEntry> getLogs() { List<LogEntry> logs = Lists.newArrayList(); String result = logoutput != null ? logoutput.toString() : ""; String[] lines = result.split("\\r?\\n"); log.fine("getting logcat"); for (String line : lines) { Level l; if (line.startsWith("I")) { l = Level.INFO; } else if (line.startsWith("W")) { l = Level.WARNING; } else if (line.startsWith("S")) { l = Level.SEVERE; } else { l = Level.FINE; } logs.add(new LogEntry(l, System.currentTimeMillis(), line)); log.fine(line); } return logs; } @Override public boolean isLoggingEnabled() { return loggingEnabled; } @Override public void setLoggingEnabled(boolean loggingEnabled) { this.loggingEnabled = loggingEnabled; } private void startLogging() { logoutput = new ByteArrayOutputStream(); DefaultExecutor exec = new DefaultExecutor(); exec.setStreamHandler(new PumpStreamHandler(logoutput)); CommandLine command = adbCommand("logcat", "ResourceType:S", "dalvikvm:S", "Trace:S", "SurfaceFlinger:S", "StrictMode:S", "ExchangeService:S", "SVGAndroid:S", "skia:S", "LoaderManager:S", "ActivityThread:S", "-v", "time"); log.info("starting logcat:"); log.fine(command.toString()); try { exec.execute(command, new DefaultExecuteResultHandler()); logcatWatchdog = new ExecuteWatchdog(ExecuteWatchdog.INFINITE_TIMEOUT); exec.setWatchdog(logcatWatchdog); } catch (IOException e) { log.log(Level.SEVERE, e.getMessage(), e); } } protected String getProp(String key) { CommandLine command = adbCommand("shell", "getprop", key); String prop = executeCommandQuietly(command); return prop == null ? "" : prop.replace("\r", "").replace("\n", ""); } protected static String extractValue(String regex, String output) { Pattern pattern = Pattern.compile(regex, Pattern.MULTILINE); Matcher matcher = pattern.matcher(output); if (matcher.find()) { return matcher.group(1); } return ""; } public boolean screenSizeMatches(String requestedScreenSize) { // if screen size is not requested, just ignore it if (requestedScreenSize == null || requestedScreenSize.isEmpty()) { return true; } Pattern dimensionPattern = Pattern.compile("([0-9]+)x([0-9]+)"); Matcher dimensionMatcher = dimensionPattern.matcher(requestedScreenSize); if (dimensionMatcher.matches()) { int width = Integer.parseInt(dimensionMatcher.group(1)); int height = Integer.parseInt(dimensionMatcher.group(2)); return getScreenSize().equals(new Dimension(width, height)); } else { return false; } } public String runAdbCommand(String parameter) { if (parameter == null || parameter.isEmpty()) { return null; } log.fine("running command: adb " + parameter); CommandLine command = adbCommand(); String[] params = parameter.split(" "); for (String param : params) { command.addArgument(param, false); } String commandOutput = executeCommandQuietly(command); return commandOutput.trim(); } public byte[] takeScreenshot() throws AndroidDeviceException { if (device == null) { throw new AndroidDeviceException("Device not accessible via ddmlib."); } RawImage rawImage; try { rawImage = device.getScreenshot(); } catch (IOException ioe) { throw new AndroidDeviceException("Unable to get frame buffer: " + ioe.getMessage()); } catch (TimeoutException e) { log.log(Level.SEVERE, e.getMessage(), e); throw new AndroidDeviceException(e.getMessage()); } catch (AdbCommandRejectedException e) { log.log(Level.SEVERE, e.getMessage(), e); throw new AndroidDeviceException(e.getMessage()); } // device/adb not available? if (rawImage == null) return null; BufferedImage image = new BufferedImage(rawImage.width, rawImage.height, BufferedImage.TYPE_3BYTE_BGR); int index = 0; int IndexInc = rawImage.bpp >> 3; for (int y = 0; y < rawImage.height; y++) { for (int x = 0; x < rawImage.width; x++) { image.setRGB(x, y, rawImage.getARGB(index)); index += IndexInc; } } return toByteArray(image); } protected byte[] toByteArray(BufferedImage image) throws AndroidDeviceException { ByteArrayOutputStream stream = new ByteArrayOutputStream(); try { if (!ImageIO.write(image, "png", stream)) { throw new IOException("Failed to find png writer"); } } catch (IOException e) { log.log(Level.SEVERE, "Cannot take screenshot", e); throw new AndroidDeviceException(e.getMessage()); } byte[] raw = null; try { stream.flush(); raw = stream.toByteArray(); stream.close(); } catch (IOException e) { throw new RuntimeException("I/O Error while capturing screenshot: " + e.getMessage()); } finally { try { stream.close(); } catch (IOException ioe) { // ignore } } return raw; } /** * Use adb to send a keyevent to the device. * * Full list of keys available here: * http://developer.android.com/reference/android/view/KeyEvent.html * * @param value - Key to be sent to 'adb shell input keyevent' */ public void inputKeyevent(int value) { executeCommandQuietly(adbCommand("shell", "input", "keyevent", "" + value)); // need to wait a beat for the UI to respond sleep(500); } public void invokeActivity(String activity) { executeCommandQuietly(adbCommand("shell", "am", "start", "-a", activity)); // need to wait a beat for the UI to respond sleep(500); } public void restartADB() { executeCommandQuietly(adbCommand("kill-server")); sleep(500); // make sure it's backup again executeCommandQuietly(adbCommand("devices")); } private CommandLine adbCommand() { CommandLine command = new CommandLine(AndroidSdk.adb()); if (isSerialConfigured()) { command.addArgument("-s", false); command.addArgument(serial, false); } return command; } private CommandLine adbCommand(String... args) { CommandLine command = adbCommand(); for (String arg : args) { command.addArgument(arg, false); } String commandString = command.toString(); if (commandString != null && commandString.length() > MAX_ADB_COMMAND_LENGTH) { throw new RuntimeException("Adb command must be under " + MAX_ADB_COMMAND_LENGTH); } return command; } public String getExternalStoragePath() { return runAdbCommand("shell echo $EXTERNAL_STORAGE"); } /** {@inheritdoc} */ public String getCrashLog() { String crashLogFileName = ExternalStorageFile.APP_CRASH_LOG.toString(); // The "test" utility doesn't exist on all devices so we'll check the output of ls. String crashLogDirPath = getExternalStoragePath(); if (!crashLogDirPath.endsWith("/")) { crashLogDirPath += "/"; // Make sure it ends with '/' so we're listing directory contents. } String directoryList = executeCommandQuietly(adbCommand("shell", "ls", crashLogDirPath)); if (directoryList.contains(crashLogFileName)) { return executeCommandQuietly(adbCommand("shell", "cat", crashLogDirPath + crashLogFileName)); } return ""; } /** {@inheritDoc} */ public String listRunningThirdPartyProcesses() { String psOutput = runAdbCommand("shell ps"); StringBuilder sb = new StringBuilder(); boolean isFirstHeaderLine = true; for (String line: Splitter.on("\n").split(psOutput)) { boolean isThirdPartyProcess = line.contains(".") && !line.contains("com.android"); if (isFirstHeaderLine || isThirdPartyProcess) { sb.append(line + "\n"); } isFirstHeaderLine = false; } return sb.toString(); } private void sleep(int millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.log(Level.SEVERE, e.getMessage(), e); } } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass() || device == null) return false; AbstractDevice that = (AbstractDevice) o; return device.equals(that.device); } @Override public int hashCode() { return device.hashCode(); } public String getModel() { return model; } public String getAPITargetType() { return apiTargetType; } }