org.iobserve.mobile.instrument.core.APKInstrumenter.java Source code

Java tutorial

Introduction

Here is the source code for org.iobserve.mobile.instrument.core.APKInstrumenter.java

Source

/***************************************************************************
 * Copyright (C) 2016 iObserve Project (https://www.iobserve-devops.net)
 *
 * 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 org.iobserve.mobile.instrument.core;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.List;

import javax.xml.bind.JAXBException;

import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.iobserve.mobile.instrument.bytecode.BytecodeInstrumentationManager;
import org.iobserve.mobile.instrument.config.InstrumentationConfiguration;

import net.lingala.zip4j.core.ZipFile;
import net.lingala.zip4j.exception.ZipException;
import net.lingala.zip4j.io.ZipInputStream;
import net.lingala.zip4j.model.FileHeader;
import net.lingala.zip4j.model.ZipParameters;

/**
 * Main class which schedules and performs the instrumentation of an Android
 * application.
 * 
 * @author Robert Heinrich
 * @author David Monschein
 *
 */
public class APKInstrumenter {
    // TEMPORARY USED FILES
    /** Temporary file for the rezipped JAR file. */
    private static final File JAR_REZIP = new File("jar-rezip.jar");

    /** Temporary file for building the current agent version. */
    private static final File AGENT_BUILD = new File("agent-current.zip");

    /** Temporary output folder for rezipping the application. */
    private static final File OUTPUT_TEMP = new File("temp");

    /** Temporary output folder for unzipping the application. */
    private static final File OUTPUT_TEMPO = new File("tempo");

    /** New created dex file. */
    private static final File TEMP_DEX_NEW = new File("temp-new.dex");

    /** Temporary extracted dex file. */
    private static final File TEMP_DEX_OLD = new File("temp-dex.dex");

    /** Temporary transformed JAR file created by dex2jar. */
    private static final File TEMP_JAR = new File("temp-jar.jar");

    // _________________________________________________ //

    /** Logger. */
    private static final Logger LOG = LogManager.getLogger(APKInstrumenter.class);

    /** Project name for the shared project. */
    private static final String PROJECTNAME_SHARED = "mobile.shared";

    /** Project name for the agent project. */
    private static final String PROJECTNAME_AGENT = "mobile.agent";

    /** Project name for the iObserve common porject. */
    private static final String PROJECTNAME_COMMON = "common";

    /** Path to the agent files. */
    private static final File AGENT_CURRENT = new File("../" + PROJECTNAME_AGENT + "/build/classes/main");

    /** Path to the agent shared files. */
    private static final File AGENT_SHARED = new File("../" + PROJECTNAME_SHARED + "/build/classes/main");

    /** Path to the common project records. */
    private static final File AGENT_RECORDS_COMMON = new File("../" + PROJECTNAME_COMMON + "/build/classes/main");

    /** Path to the libs used by the agent. */
    private static final File AGENT_LIB_CURRENT = new File("../" + PROJECTNAME_AGENT + "/build/libs");

    /** Path to build the agent libs. */
    private static final File AGENT_LIB_BUILD_TEMP = new File(AGENT_LIB_CURRENT.getAbsolutePath() + "/temp/");

    /** Path which is used to save a modified manifest file. */
    private static final File MODIFIED_MANIFEST = new File("modified_manifest.xml");

    /** Byte to Megabyte factor. */
    private static final double MB_FACTOR = 1024d * 1024d;

    /** Flags if we override an existing output or not. */
    private boolean override;

    /** Path to the keystore used to resign the application. */
    private File keystore;

    /** Alias of the keystore. */
    private String alias;

    /** Password for the keystore. */
    private String pass;

    /** Permissions which are needed by the agent. */
    private List<String> neededRights;

    /** Flags whether to build the agent or not. */
    private boolean buildAgent = true;

    /** Flags whether to cleanup before instrumentation. */
    private boolean cleanBefore = true;

    /** Flags whether to cleanup after instrumentation. */
    private boolean cleanAfter = true;

    /** Flags whether to adjust the manifest or not. (recommended) */
    private boolean adjustManifest = true;

    /**
     * Creates a new instance for instrumenting Android applications.
     * 
     * @param override
     *            whether to override existing files or not
     * @param keystore
     *            the path to the keystore
     * @param alias
     *            the alias of the keystore
     * @param pass
     *            the password of the keystore
     */
    public APKInstrumenter(final boolean override, final File keystore, final String alias, final String pass) {
        this.setOverride(override);
        this.setKeystore(keystore);
        this.setAlias(alias);
        this.setPass(pass);
    }

    /**
     * Instruments an android application.
     * 
     * @param input
     *            the input application
     * @param output
     *            the path where to save the instrumented application
     * @return true if success - false otherwise
     * @throws IOException
     *             if there is an I/O problem
     * @throws ZipException
     *             if zip4j can't zip/unzip your application files
     * @throws KeyStoreException
     *             if there is a problem with the used keystore
     * @throws NoSuchAlgorithmException
     *             algorithm not found
     * @throws CertificateException
     *             certificate error
     * @throws URISyntaxException
     *             URI syntax problem
     */
    public boolean instrumentAPK(final File input, final File output) throws IOException, ZipException,
            KeyStoreException, NoSuchAlgorithmException, CertificateException, URISyntaxException {
        // CHECK IF INPUT EXISTS AND OUTPUT DOESNT
        if (!input.exists() || (output.exists() && !override)) {
            return false;
        }

        // LOAD CONFIGURATION
        final InstrumentationConfiguration instrConfig = new InstrumentationConfiguration();
        final File instrConfigFile = new File(APKInstrumenter.class.getResource("/config/default.xml").toURI());
        try {
            instrConfig.parseConfigFile(instrConfigFile);
        } catch (JAXBException e) {
            LOG.error("Failed to load configuration.");
            return false;
        }

        LOG.info("Successfully loaded instrumentation config '" + instrConfigFile.getAbsolutePath() + "'.");

        // INSERT RIGHTS NEEDED
        this.neededRights = instrConfig.getXmlConfiguration().getManifestTransformer().getPermissions();

        // CLEAN
        if (cleanBefore) {
            cleanUpAll();
        }

        // ZIP CURRENT AGENT VERSION
        if (buildAgent) {
            buildAgentInner(instrConfig);
        }

        // ADD MANIFEST ADJUSTMENTS
        if (adjustManifest) {
            LOG.info("Decoding application with APKTool.");

            final APKToolProxy apkTool = new APKToolProxy(input);
            final boolean b1 = apkTool.decodeAPK("intermediate");
            final boolean b2 = apkTool.adjustManifest(neededRights, MODIFIED_MANIFEST);

            if (!b1 || !b2) {
                adjustManifest = false;
            } else {
                instrConfig.setApplicationPackage(apkTool.getPackageName());
            }
            apkTool.cleanup();

            LOG.info("APKTool finished decoding the application.");
        }

        // IF OVERRIDE DELETE IT
        if (output.exists()) {
            if (!output.delete()) {
                return false;
            }
        }

        if (!AGENT_BUILD.exists()) {
            return false;
        }

        // COPY INPUT TO OUTPUT
        final File tempOutputFolder = OUTPUT_TEMPO;
        tempOutputFolder.mkdir();
        final ZipFile tempInputZip = new ZipFile(input);
        tempInputZip.extractAll(tempOutputFolder.getAbsolutePath() + "/");

        LOG.info("Created copy of original APK file.");

        // TEMP WRITE DEX
        final File tempDex = TEMP_DEX_OLD;
        unzipDex(input, tempDex);

        LOG.info("Searched and extracted dex files.");

        // DEX 2 JAR
        LOG.info("Executing dex2jar to transform DEX files to JAR files.");
        final File tempJar = new File("temp-jar.jar");
        Dex2JarProxy.createJarFromDex(tempDex, tempJar);
        LOG.info("Dex2jar finished transformation.");

        // UNZIP JAR
        final File jarFilesContainer = OUTPUT_TEMP;
        jarFilesContainer.mkdir();

        final ZipFile jarZip = new ZipFile(tempJar);
        jarZip.extractAll(jarFilesContainer.getAbsolutePath() + "/");

        LOG.info("Extracted JAR file for accessing class files.");

        LOG.info("Started instrumentation of class files.");
        final ClassFileCollector cfColl = new ClassFileCollector(jarFilesContainer);
        cfColl.collectClassFiles();

        final BytecodeInstrumentationManager manager = new BytecodeInstrumentationManager(cfColl.getClassFiles());
        manager.executeInstrumentation(instrConfig);
        LOG.info("Successfully instrumented class files.");

        // INJECT AGENT
        LOG.info("Injecting agent files into rezipped JAR.");
        final ZipFile agentZipfile = new ZipFile(AGENT_BUILD);
        agentZipfile.extractAll(jarFilesContainer.getAbsolutePath() + "/");
        LOG.info("Agent has been injected.");

        // CREATE ZIP
        LOG.info("Rebuilding JAR file from modified class files.");
        final File tempRezipFile = rebuildJar(jarFilesContainer);
        LOG.info("Finished building new JAR file.");

        // JAR 2 DEX
        final File tempNewDex = TEMP_DEX_NEW;
        LOG.info("Executing jar2dex to transform new JAR file to DEX.");
        Dex2JarProxy.createDexFromJar(tempRezipFile, tempNewDex);
        LOG.info("jar2dex has been successfully executed.");

        // EDIT OLD classes.dex and remove META INF
        LOG.info("Removing old signature.");
        final File oldMeta = new File(tempOutputFolder.getAbsolutePath() + "/META-INF");
        FileUtils.deleteDirectory(oldMeta);

        final File oClasses = new File(tempOutputFolder.getAbsolutePath() + "/classes.dex");
        oClasses.delete();
        Files.copy(tempNewDex.toPath(), oClasses.toPath());

        // MODIFY MANIFEST
        if (adjustManifest) {
            LOG.info("Adjusting Android manifest.");
            final File oManifest = new File(tempOutputFolder.getAbsolutePath() + "/AndroidManifest.xml");
            oManifest.delete();
            Files.copy(MODIFIED_MANIFEST.toPath(), oManifest.toPath());
        }

        // REZIP
        LOG.info("Rezip all files to output APK.");
        rezipOutput(output, tempOutputFolder);

        // RESIGN
        LOG.info("Resigning the output APK.");
        final JarSigner signer = new JarSigner();
        signer.signJar(output, getKeystore(), getAlias(), getPass());

        // REMOVE ALL TEMP FILES
        if (cleanAfter) {
            cleanUpAll();
        }

        LOG.info("Instrumentation finished.");
        LOG.info("Instrumented application is located at '" + output.getAbsolutePath() + "'.");

        return true;
    }

    /**
     * @return the override
     */
    public boolean isOverride() {
        return override;
    }

    /**
     * @param override
     *            the override to set
     */
    public void setOverride(final boolean override) {
        this.override = override;
    }

    /**
     * @return the keystore
     */
    public File getKeystore() {
        return keystore;
    }

    /**
     * @param keystore
     *            the keystore to set
     */
    public void setKeystore(final File keystore) {
        this.keystore = keystore;
    }

    /**
     * @return the alias
     */
    public String getAlias() {
        return alias;
    }

    /**
     * @param alias
     *            the alias to set
     */
    public void setAlias(final String alias) {
        this.alias = alias;
    }

    /**
     * @return the pass
     */
    public String getPass() {
        return pass;
    }

    /**
     * @param pass
     *            the pass to set
     */
    public void setPass(final String pass) {
        this.pass = pass;
    }

    /**
     * @return the buildAgent
     */
    public boolean isBuildAgent() {
        return buildAgent;
    }

    /**
     * @param buildAgent
     *            the buildAgent to set
     */
    public void setBuildAgent(final boolean buildAgent) {
        this.buildAgent = buildAgent;
    }

    /**
     * @return the cleanBefore
     */
    public boolean isCleanBefore() {
        return cleanBefore;
    }

    /**
     * @param cleanBefore
     *            the cleanBefore to set
     */
    public void setCleanBefore(final boolean cleanBefore) {
        this.cleanBefore = cleanBefore;
    }

    /**
     * @return the cleanAfter
     */
    public boolean isCleanAfter() {
        return cleanAfter;
    }

    /**
     * @param cleanAfter
     *            the cleanAfter to set
     */
    public void setCleanAfter(final boolean cleanAfter) {
        this.cleanAfter = cleanAfter;
    }

    /**
     * Builds the agent with all class files and libraries.
     * 
     * @param instrConfig
     *            the configuration of the instrumentation
     * @throws IOException
     *             if not all files could be resolved
     * @throws ZipException
     *             if zipping to agent build fails
     */
    private void buildAgentInner(final InstrumentationConfiguration instrConfig) throws IOException, ZipException {
        LOG.info("Bundling current agent version.");
        final File agentZipOld = AGENT_BUILD;
        agentZipOld.delete();
        FileUtils.deleteDirectory(AGENT_LIB_BUILD_TEMP);

        final ZipFile agentZipNew = new ZipFile(AGENT_BUILD);

        // TO NOT ADD INCONSISTENT FOLDERS
        FileUtils.copyDirectory(AGENT_SHARED, AGENT_CURRENT);
        FileUtils.copyDirectory(AGENT_RECORDS_COMMON, AGENT_CURRENT);

        final File[] libs = AGENT_LIB_CURRENT.listFiles();
        for (File lib : libs) {
            if (lib.isFile() && !lib.getName().contains("org.iobserve.mobile.agent")) {
                final ZipFile zipF = new ZipFile(lib);
                zipF.extractAll(AGENT_LIB_BUILD_TEMP.getAbsolutePath());
            }
        }

        final File toAdd = AGENT_LIB_BUILD_TEMP;
        final List<String> includedFolders = instrConfig.getXmlConfiguration().getAgentBuildConfiguration()
                .getLibraryFolders();

        for (String includedFolder : includedFolders) {
            final File[] toAddArray = new File(toAdd.getAbsolutePath() + "/" + includedFolder).listFiles();
            if (toAddArray != null) {
                final File containingFolder = new File(AGENT_CURRENT.getAbsolutePath() + "/" + includedFolder);
                if (!containingFolder.exists()) {
                    containingFolder.mkdir();
                }
                for (File ff : toAddArray) {
                    if (ff.isDirectory()) {
                        FileUtils.copyDirectoryToDirectory(ff, containingFolder);
                    } else {
                        FileUtils.copyFile(ff, new File(containingFolder.getAbsolutePath() + "/" + ff.getName()));
                    }
                }
            }
        }

        addFolderToZipNoParent(agentZipNew, AGENT_CURRENT);

        LOG.info("Bundled agent with an actual size of " + round(agentZipNew.getFile().length() / MB_FACTOR)
                + "MB.");
    }

    /**
     * Creates a jar file from a folder which contains the bytecode files.
     * 
     * @param jarFilesContainer
     *            folder which contains bytecode files
     * @return path to the created jar file
     * @throws ZipException
     *             if zip4j fails
     */
    private File rebuildJar(final File jarFilesContainer) throws ZipException {
        this.rezipOutput(JAR_REZIP, jarFilesContainer);

        return JAR_REZIP;
    }

    /**
     * Rezips a folder to a zip file.
     * 
     * @param output
     *            the zip file to create
     * @param tempOutputFolder
     *            the folder which should be zipped
     * @throws ZipException
     *             if zip4j fails
     */
    private void rezipOutput(final File output, final File tempOutputFolder) throws ZipException {
        final ZipFile zipOutput = new ZipFile(output);
        addFolderToZipNoParent(zipOutput, tempOutputFolder);
    }

    /**
     * Adds a folder to a zip file without the parent folder.
     * 
     * @param zip
     *            the zip file
     * @param folder
     *            the folder to add
     * @throws ZipException
     *             if there is an I/O problem
     */
    private void addFolderToZipNoParent(final ZipFile zip, final File folder) throws ZipException {
        final ZipParameters parameters = new ZipParameters();
        for (File fInner : folder.listFiles()) {
            if (fInner.isDirectory()) {
                zip.addFolder(fInner, parameters);
            } else {
                zip.addFile(fInner, parameters);
            }
        }
    }

    /**
     * Cleans all folders and files temporary used for instrumentation.
     * 
     * @throws IOException
     *             if not all files and folders could be removed successfully
     */
    private void cleanUpAll() throws IOException {
        LOG.info("Cleaning up all folders.");

        FileUtils.deleteDirectory(OUTPUT_TEMP);
        FileUtils.deleteDirectory(OUTPUT_TEMPO);

        TEMP_JAR.delete();
        TEMP_DEX_NEW.delete();
        JAR_REZIP.delete();
        TEMP_DEX_OLD.delete();
        MODIFIED_MANIFEST.delete();
    }

    /**
     * Extracts only a the classes.dex file from an Android application.
     * 
     * @param apk
     *            the Android application
     * @param dex
     *            the path where the dex should be saved
     * @throws ZipException
     *             if zip4j fails
     * @throws IOException
     *             if there is an I/O problem
     */
    private void unzipDex(final File apk, final File dex) throws ZipException, IOException {
        if (apk.exists() && !dex.exists()) {
            final ZipFile parent = new ZipFile(apk);

            @SuppressWarnings("unchecked")
            final List<FileHeader> headerList = parent.getFileHeaders();

            for (FileHeader header : headerList) {
                if (header.getFileName().equals("classes.dex")) {
                    final ZipInputStream in = parent.getInputStream(header);
                    final FileOutputStream os = new FileOutputStream(dex);
                    int readLen = -1;
                    final byte[] buff = new byte[4096];
                    while ((readLen = in.read(buff)) != -1) {
                        os.write(buff, 0, readLen);
                    }
                    closeStreams(in, os);
                    break;
                }
            }
        }
    }

    /**
     * Closes two input streams.
     * 
     * @param a
     *            a zip input stream
     * @param b
     *            a file output stream
     * @throws IOException
     *             if one of the streams can't be closed
     */
    private void closeStreams(final ZipInputStream a, final FileOutputStream b) throws IOException {
        if (a != null) {
            a.close();
        }

        if (b != null) {
            b.close();
        }
    }

    /**
     * Rounds a double to two decimal places.
     * 
     * @param a
     *            input double
     * @return rounded double
     */
    private double round(final double a) {
        return Math.round(a * 100d) / 100d;
    }
}