/* * 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.builder; import io.selendroid.server.common.exceptions.SelendroidException; import io.selendroid.standalone.SelendroidConfiguration; import io.selendroid.standalone.android.AndroidApp; import io.selendroid.standalone.android.AndroidSdk; import io.selendroid.standalone.android.JavaSdk; import io.selendroid.standalone.android.impl.DefaultAndroidApp; import io.selendroid.standalone.exceptions.AndroidSdkException; import io.selendroid.standalone.exceptions.ShellCommandException; import io.selendroid.standalone.io.ShellCommand; import io.selendroid.standalone.server.model.SelendroidStandaloneDriver; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.apache.commons.compress.archivers.zip.ZipFile; import org.apache.commons.exec.CommandLine; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import com.google.common.io.Files; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URL; import java.nio.charset.Charset; import java.security.KeyStore; import java.security.cert.X509Certificate; import java.util.Enumeration; import java.util.jar.Attributes; import java.util.jar.Manifest; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; public class SelendroidServerBuilder { public static final String SELENDROID_TEST_APP_PACKAGE = "io.selendroid.testapp"; private static final Logger log = Logger.getLogger(SelendroidServerBuilder.class.getName()); public static final String SELENDROID_FINAL_NAME = "selendroid-server.apk"; public static final String PREBUILD_SELENDROID_SERVER_PATH_PREFIX = "/prebuild/selendroid-server-"; public static final String ANDROID_APPLICATION_XML_TEMPLATE = "/AndroidManifestTemplate.xml"; public static final String ICON = "android:icon=\"@drawable/selenium_icon\""; private String selendroidPrebuildServerPath = null; private String selendroidApplicationXmlTemplate = null; private AndroidApp selendroidServer = null; private AndroidApp applicationUnderTest = null; private SelendroidConfiguration serverConfiguration = null; private String storepass = "android"; private String alias = "androiddebugkey"; private X509Certificate cert509; /** * FOR TESTING ONLY */ /* package */SelendroidServerBuilder(String selendroidPrebuildServerPath, String selendroidApplicationXmlTemplate) { this(selendroidPrebuildServerPath, selendroidApplicationXmlTemplate, null); } /** * FOR TESTING ONLY */ /* package */SelendroidServerBuilder(String selendroidPrebuildServerPath, String selendroidApplicationXmlTemplate, SelendroidConfiguration selendroidConfiguration) { this.selendroidPrebuildServerPath = selendroidPrebuildServerPath; this.selendroidApplicationXmlTemplate = selendroidApplicationXmlTemplate; this.serverConfiguration = selendroidConfiguration; } public SelendroidServerBuilder(SelendroidConfiguration selendroidConfiguration) { this(PREBUILD_SELENDROID_SERVER_PATH_PREFIX + getJarVersionNumber() + ".apk", ANDROID_APPLICATION_XML_TEMPLATE, selendroidConfiguration); } /* package */void init(AndroidApp aut) throws IOException, ShellCommandException { applicationUnderTest = aut; File customizedServer = File.createTempFile("selendroid-server", ".apk"); if (deleteTmpFiles()) { customizedServer.deleteOnExit(); //Deletes temporary files created } log.info("Creating customized Selendroid-server: " + customizedServer.getAbsolutePath()); InputStream is = getResourceAsStream(selendroidPrebuildServerPath); IOUtils.copy(is, new FileOutputStream(customizedServer)); IOUtils.closeQuietly(is); selendroidServer = new DefaultAndroidApp(customizedServer); } public AndroidApp createSelendroidServer(AndroidApp aut) throws IOException, ShellCommandException, AndroidSdkException { log.info("create SelendroidServer for apk: " + aut.getAbsolutePath()); init(aut); cleanUpPrebuildServer(); File selendroidServer = createAndAddCustomizedAndroidManifestToSelendroidServer(); File outputFile = File.createTempFile( String.format("selendroid-server-%s-%s", applicationUnderTest.getBasePackage(), getJarVersionNumber()), ".apk" ); if (deleteTmpFiles()) { outputFile.deleteOnExit(); //Deletes file when done } return signTestServer(selendroidServer, outputFile); } private void deleteFileFromAppSilently(AndroidApp app, String file) throws AndroidSdkException { if (app == null) { throw new IllegalArgumentException("Required parameter 'app' is null."); } if (file == null || file.isEmpty()) { throw new IllegalArgumentException("Required parameter 'file' is null or empty."); } try { app.deleteFileFromWithinApk(file); } catch (ShellCommandException e) { // don't care, can happen if file does not exist } } public AndroidApp resignApp(File appFile) throws ShellCommandException, AndroidSdkException, IOException { AndroidApp app = new DefaultAndroidApp(appFile); // Delete existing certificates deleteFileFromAppSilently(app, "META-INF/MANIFEST.MF"); deleteFileFromAppSilently(app, "META-INF/CERT.RSA"); deleteFileFromAppSilently(app, "META-INF/CERT.SF"); deleteFileFromAppSilently(app, "META-INF/ANDROIDD.SF"); deleteFileFromAppSilently(app, "META-INF/ANDROIDD.RSA"); deleteFileFromAppSilently(app, "META-INF/NDKEYSTO.SF"); deleteFileFromAppSilently(app, "META-INF/NDKEYSTO.RSA"); File outputFile = File.createTempFile("resigned-", appFile.getName()); if (deleteTmpFiles()) { outputFile.deleteOnExit(); } return signTestServer(appFile, outputFile); } /* package */File createAndAddCustomizedAndroidManifestToSelendroidServer() throws IOException, ShellCommandException, AndroidSdkException { String targetPackageName = applicationUnderTest.getBasePackage(); File tmpDir = Files.createTempDir(); if (deleteTmpFiles()) { tmpDir.deleteOnExit(); } File customizedManifest = new File(tmpDir, "AndroidManifest.xml"); if (deleteTmpFiles()) { customizedManifest.deleteOnExit(); } log.info("Adding target package '" + targetPackageName + "' to " + customizedManifest.getAbsolutePath()); // add target package InputStream inputStream = getResourceAsStream(selendroidApplicationXmlTemplate); if (inputStream == null) { throw new SelendroidException("AndroidApplication.xml template file was not found."); } String content = IOUtils.toString(inputStream, Charset.defaultCharset().displayName()); // find the first occurance of "package" and appending the targetpackagename to begining int i = content.toLowerCase().indexOf("package"); int cnt = 0; for (; i < content.length(); i++) { if (content.charAt(i) == '\"') { cnt++; } if (cnt == 2) { break; } } content = content.substring(0, i) + "." + targetPackageName + content.substring(i); log.info("Final Manifest File:\n" + content); content = content.replaceAll(SELENDROID_TEST_APP_PACKAGE, targetPackageName); // Seems like this needs to be done if (content.contains(ICON)) { content = content.replaceAll(ICON, ""); } OutputStream outputStream = new FileOutputStream(customizedManifest); IOUtils.write(content, outputStream, Charset.defaultCharset().displayName()); IOUtils.closeQuietly(inputStream); IOUtils.closeQuietly(outputStream); // adding the xml to an empty apk CommandLine createManifestApk = new CommandLine(AndroidSdk.aapt()); File manifestApkFile = File.createTempFile("manifest", ".apk"); if (deleteTmpFiles()) { manifestApkFile.deleteOnExit(); } createManifestApk.addArgument("package", false); createManifestApk.addArgument("-M", false); createManifestApk.addArgument(customizedManifest.getAbsolutePath(), false); createManifestApk.addArgument("-I", false); createManifestApk.addArgument(AndroidSdk.androidJar(), false); createManifestApk.addArgument("-F", false); createManifestApk.addArgument(manifestApkFile.getAbsolutePath(), false); createManifestApk.addArgument("-f", false); log.info(ShellCommand.exec(createManifestApk, 20000L)); ZipFile manifestApk = new ZipFile(manifestApkFile); ZipArchiveEntry binaryManifestXml = manifestApk.getEntry("AndroidManifest.xml"); File finalSelendroidServerFile = File.createTempFile("selendroid-server", ".apk"); if (deleteTmpFiles()) { finalSelendroidServerFile.deleteOnExit(); } ZipArchiveOutputStream finalSelendroidServer = new ZipArchiveOutputStream(finalSelendroidServerFile); finalSelendroidServer.putArchiveEntry(binaryManifestXml); IOUtils.copy(manifestApk.getInputStream(binaryManifestXml), finalSelendroidServer); ZipFile selendroidPrebuildApk = new ZipFile(selendroidServer.getAbsolutePath()); Enumeration<ZipArchiveEntry> entries = selendroidPrebuildApk.getEntries(); for (; entries.hasMoreElements(); ) { ZipArchiveEntry dd = entries.nextElement(); finalSelendroidServer.putArchiveEntry(dd); IOUtils.copy(selendroidPrebuildApk.getInputStream(dd), finalSelendroidServer); } finalSelendroidServer.closeArchiveEntry(); finalSelendroidServer.close(); manifestApk.close(); log.info("file: " + finalSelendroidServerFile.getAbsolutePath()); return finalSelendroidServerFile; } /* package */AndroidApp signTestServer(File customSelendroidServer, File outputFileName) throws ShellCommandException, AndroidSdkException { if (outputFileName == null) { throw new IllegalArgumentException("outputFileName parameter is null."); } File androidKeyStore = androidDebugKeystore(); if (!androidKeyStore.isFile()) { // create a new keystore CommandLine commandline = new CommandLine(JavaSdk.keytool()); commandline.addArgument("-genkey", false); commandline.addArgument("-v", false); commandline.addArgument("-keystore", false); commandline.addArgument(androidKeyStore.toString(), false); commandline.addArgument("-storepass", false); commandline.addArgument(storepass, false); commandline.addArgument("-alias", false); commandline.addArgument(alias, false); commandline.addArgument("-keypass", false); commandline.addArgument(storepass, false); commandline.addArgument("-dname", false); commandline.addArgument("CN=Android Debug,O=Android,C=US", false); commandline.addArgument("-storetype", false); commandline.addArgument("JKS", false); commandline.addArgument("-sigalg", false); commandline.addArgument("MD5withRSA", false); commandline.addArgument("-keyalg", false); commandline.addArgument("RSA", false); commandline.addArgument("-validity", false); commandline.addArgument("9999", false); String output = ShellCommand.exec(commandline, 20000); log.info("A new keystore has been created: " + output); } // Sign the jar CommandLine commandline = new CommandLine(JavaSdk.jarsigner()); commandline.addArgument("-sigalg", false); commandline.addArgument(getSigAlg(), false); commandline.addArgument("-digestalg", false); commandline.addArgument("SHA1", false); commandline.addArgument("-signedjar", false); commandline.addArgument(outputFileName.getAbsolutePath(), false); commandline.addArgument("-storepass", false); commandline.addArgument(storepass, false); commandline.addArgument("-keystore", false); commandline.addArgument(androidKeyStore.toString(), false); commandline.addArgument(customSelendroidServer.getAbsolutePath(), false); commandline.addArgument(alias, false); String output = ShellCommand.exec(commandline, 20000); if (log.isLoggable(Level.INFO)) { log.info("App signing output: " + output); } log.info("The app has been signed: " + outputFileName.getAbsolutePath()); return new DefaultAndroidApp(outputFileName); } private File androidDebugKeystore() { if (serverConfiguration == null || serverConfiguration.getKeystore() == null) { return new File(FileUtils.getUserDirectory(), File.separatorChar + ".android" + File.separatorChar + "debug.keystore"); } else { if (serverConfiguration.getKeystorePassword() != null) { storepass = serverConfiguration.getKeystorePassword(); } if (serverConfiguration.getKeystoreAlias() != null) { alias = serverConfiguration.getKeystoreAlias(); } // there is a possibility that keystore path may be invalid due to user typo. Should we add a try catch? return new File(serverConfiguration.getKeystore()); } } /** * Cleans the selendroid server by removing certificates and manifest file. <p/> Precondition: * {@link #init(AndroidApp)} must be called upfront for initialization */ /* package */void cleanUpPrebuildServer() throws ShellCommandException, AndroidSdkException { selendroidServer.deleteFileFromWithinApk("META-INF/CERT.RSA"); selendroidServer.deleteFileFromWithinApk("META-INF/CERT.SF"); selendroidServer.deleteFileFromWithinApk("AndroidManifest.xml"); } /** * for testing only */ /* package */AndroidApp getSelendroidServer() { return selendroidServer; } /** * for testing only */ /* package */AndroidApp getApplicationUnderTest() { return applicationUnderTest; } /** * Loads resources as stream and the main reason for having the method is because it can be use * while testing and in production for loading files from within jar file. * * @param resource The resource to load. * @return The input stream of the resource. * @throws SelendroidException if resource was not found. */ private InputStream getResourceAsStream(String resource) { InputStream is = getClass().getResourceAsStream(resource); // switch needed for testability if (is == null) { try { is = new FileInputStream(new File(resource)); } catch (FileNotFoundException e) { // do nothing } } if (is == null) { throw new SelendroidException("The resource '" + resource + "' was not found."); } return is; } // TODO this should go into a utility method public static String getJarVersionNumber() { Class clazz = SelendroidStandaloneDriver.class; String className = clazz.getSimpleName() + ".class"; String classPath = clazz.getResource(className).toString(); String version = ""; if (!classPath.startsWith("jar")) { // Class not from JAR if(classPath.startsWith("file") && classPath.contains("target")) { try { version = getVersionFromPom(classPath.substring(5, classPath.lastIndexOf("target"))); } catch (Exception e) { e.printStackTrace(); return ""; } } else return "dev"; } else { try { version = getVersionFromManifest(classPath); } catch (IOException e){ return ""; } } return version; } private static String getVersionFromManifest(String path) throws IOException { String manifestPath = path.substring(0, path.lastIndexOf("!") + 1) + "/META-INF/MANIFEST.MF"; Manifest manifest = new Manifest(new URL(manifestPath).openStream()); Attributes attr = manifest.getMainAttributes(); return attr.getValue("version"); } private static String getVersionFromPom(String path) throws Exception { path += "pom.xml"; Pattern regex = Pattern.compile("<version>(.*?)</version>", Pattern.DOTALL); Matcher matcher = regex.matcher(FileUtils.readFileToString(new File(path))); if (matcher.find()) { return matcher.group(1); } else return "dev"; } private String getSigAlg() { String sigAlg = "MD5withRSA"; FileInputStream in; try { if (serverConfiguration != null) { String keystoreFile = serverConfiguration.getKeystore(); if (keystoreFile != null) { in = new FileInputStream(keystoreFile); KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); String keystorePassword = serverConfiguration.getKeystorePassword(); char[] keystorePasswordCharArray = (keystorePassword == null) ? null : keystorePassword.toCharArray(); if (keystorePasswordCharArray == null) { throw new RuntimeException("No keystore password configured."); } keystore.load(in, keystorePasswordCharArray); cert509 = (X509Certificate) keystore.getCertificate(serverConfiguration.getKeystoreAlias()); sigAlg = cert509.getSigAlgName(); } } } catch (Exception e) { log.log(Level.WARNING, String.format( "Error getting signature algorithm for jarsigner. Defaulting to %s. Reason: %s", sigAlg, e.getMessage())); } return sigAlg; } private boolean deleteTmpFiles() { return serverConfiguration != null && serverConfiguration.isDeleteTmpFiles(); } }