it.evilsocket.dsploit.core.UpdateService.java Source code

Java tutorial

Introduction

Here is the source code for it.evilsocket.dsploit.core.UpdateService.java

Source

/*
 * This file is part of the dSploit.
 *
 * Copyleft of Simone Margaritelli aka evilsocket <evilsocket@gmail.com>
 *
 * dSploit is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * dSploit is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with dSploit.  If not, see <http://www.gnu.org/licenses/>.
 */
package it.evilsocket.dsploit.core;

import android.app.IntentService;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.support.v4.app.NotificationCompat;

import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.ArchiveInputStream;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream;
import org.apache.commons.compress.utils.CountingInputStream;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
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.InputStreamReader;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.KeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.regex.Pattern;

import it.evilsocket.dsploit.R;
import it.evilsocket.dsploit.net.GemParser;
import it.evilsocket.dsploit.net.GitHubParser;
import it.evilsocket.dsploit.core.ArchiveMetadata.archiveAlgorithm;
import it.evilsocket.dsploit.core.ArchiveMetadata.compressionAlgorithm;

public class UpdateService extends IntentService {
    // Resources defines
    private static final String REMOTE_VERSION_URL = "http://update.dsploit.net/version";
    private static final String REMOTE_APK_URL = "http://update.dsploit.net/apk";
    private static final String VERSION_CHAR_MAP = "zyxwvutsrqponmlkjihgfedcba";
    private static final String REMOTE_RUBY_VERSION_URL = "https://gist.githubusercontent.com/tux-mind/e594b1cf923183cfcdfe/raw/ruby.json";
    private static final String REMOTE_GEMS_VERSION_URL = "https://gist.githubusercontent.com/tux-mind/9c85eced88fd88367fa9/raw/gems.json";
    private static final String REMOTE_MSF_URL = "https://github.com/rapid7/metasploit-framework/archive/%s.zip";
    private static final String LOCAL_MSF_NAME = "%s.zip";
    private static final String REMOTE_GEM_SERVER = "http://gems.dsploit.net/";
    private static final Pattern VERSION_CHECK = Pattern.compile("^[0-9]+\\.[0-9]+\\.[0-9]+[a-z]?$");

    // Intent defines
    public static final String START = "UpdateService.action.START";
    public static final String ERROR = "UpdateService.action.ERROR";
    public static final String DONE = "UpdateService.action.DONE";
    public static final String ACTION = "UpdateService.data.ACTION";
    public static final String MESSAGE = "UpdateService.data.MESSAGE";

    // notification defines
    private static final int NOTIFICATION_ID = 1;
    private static final int DOWNLOAD_COMPLETE_CODE = 1;
    private static final int CANCEL_CODE = 2;
    private static final String NOTIFICATION_CANCELLED = "it.evilsocket.dSploit.core.UpdateService.CANCELLED";

    // remote data
    private static final ArchiveMetadata mApkInfo = new ArchiveMetadata();
    private static final ArchiveMetadata mMsfInfo = new ArchiveMetadata();
    private static final ArchiveMetadata mRubyInfo = new ArchiveMetadata();
    private static final GitHubParser mMsfRepoParser = new GitHubParser("rapid7", "metasploit-framework");
    private static final GemParser mGemUploadParser = new GemParser(REMOTE_GEMS_VERSION_URL);

    private boolean mRunning = false;
    private ArchiveMetadata mCurrentTask = null;
    final private StringBuffer mErrorOutput = new StringBuffer();
    private Shell.OutputReceiver mErrorReceiver = null;

    private NotificationManager mNotificationManager = null;
    private NotificationCompat.Builder mBuilder = null;
    private BroadcastReceiver mReceiver = null;

    public enum action {
        apk_update, ruby_update, gems_update, msf_update
    }

    public UpdateService() {
        super("UpdateService");
        // prepare error receiver
        mErrorReceiver = new Shell.OutputReceiver() {
            @Override
            public void onStart(String command) {
                mErrorOutput.delete(0, mErrorOutput.length());
                mErrorOutput.append("running: ");
                mErrorOutput.append(command);
                mErrorOutput.append("\n");
            }

            @Override
            public void onNewLine(String line) {
                mErrorOutput.append(line);
                mErrorOutput.append("\n");
            }

            @Override
            public void onEnd(int exitCode) {
                mErrorOutput.append("exitValue: ");
                mErrorOutput.append(exitCode);
            }
        };
    }

    /**
     * <p>
     * parse a string containing the version of the apk
     * into a double for easily compare them.
     * </p>
     * <p>
     * the algorithm works as follows:
     * </p>
     * <p>
     * {@code version = "1.2.3d"}
     * <br/>
     * {@code output = (((1+1) * 1000) + ((2+1) * 100) + ((3+1) * 1)) - ((charOffset+1) / 100.0)}
     * <br/>
     * {@code output = 2304.77}
     * </p>
     * <p>
     * where {@code charOffset} is the distance of the letter from the 'z'
     * in the ASCII table.
     * </p>
     * @param version the apk version
     * @return the input version represented as double
     */
    private static double getVersionCode(String version) {
        double code = 0, multipliers[] = { 1000, 100, 1 };
        String parts[] = version.split("[^0-9a-zA-Z]", 3), item, digit, letter;
        int i, j;
        char c;

        for (i = 0; i < 3; i++) {
            item = parts[i];

            if (item.matches("\\d+[a-zA-Z]")) {
                digit = "";
                letter = "";

                for (j = 0; j < item.length(); j++) {
                    c = item.charAt(j);
                    if (c >= '0' && c <= '9')
                        digit += c;
                    else
                        letter += c;
                }

                code += multipliers[i] * (Integer.parseInt(digit) + 1)
                        - ((VERSION_CHAR_MAP.indexOf(letter.toLowerCase()) + 1) / 100.0);
            } else if (item.matches("\\d+"))
                code += multipliers[i] * (Integer.parseInt(item) + 1);

            else
                code += multipliers[i];
        }

        return code;
    }

    public static boolean isUpdateAvailable() {
        boolean exitForError = true;
        String localVersion = System.getAppVersionName();

        // cannot retrieve installed apk version
        if (localVersion == null)
            return false;

        try {
            synchronized (mApkInfo) {
                // Read remote version
                if (mApkInfo.version == null) {
                    URL url = new URL(REMOTE_VERSION_URL);
                    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                    BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
                    String line, buffer = "";

                    while ((line = reader.readLine()) != null) {
                        buffer += line + "\n";
                    }

                    reader.close();
                    mApkInfo.url = REMOTE_APK_URL;
                    mApkInfo.versionString = buffer.split("\n")[0].trim();
                    if (!VERSION_CHECK.matcher(mApkInfo.versionString).matches())
                        throw new org.apache.http.ParseException(
                                String.format("remote version parse failed: '%s'", mApkInfo.versionString));
                    mApkInfo.version = getVersionCode(mApkInfo.versionString);
                    mApkInfo.name = String.format("dSploit-%s.apk", mApkInfo.versionString);
                    mApkInfo.path = String.format("%s/%s", System.getStoragePath(), mApkInfo.name);
                    mApkInfo.contentIntent = new Intent(Intent.ACTION_VIEW);
                    mApkInfo.contentIntent.setDataAndType(Uri.fromFile(new File(mApkInfo.path)),
                            "application/vnd.android.package-archive");
                    mApkInfo.contentIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                }

                // Compare versions
                double installedVersionCode = getVersionCode(localVersion);

                Logger.debug(
                        String.format("mApkInstalledVersion = %s ( %s ) ", localVersion, installedVersionCode));
                Logger.debug(String.format("mRemoteVersion       = %s ( %s ) ", mApkInfo.versionString,
                        mApkInfo.version));

                exitForError = false;

                if (mApkInfo.version > installedVersionCode)
                    return true;
            }
        } catch (org.apache.http.ParseException e) {
            Logger.error(e.getMessage());
        } catch (Exception e) {
            System.errorLogging(e);
        } finally {
            if (exitForError)
                mApkInfo.reset();
        }

        return false;
    }

    public static String getRemoteVersion() {
        return mApkInfo.versionString;
    }

    /**
     * is ruby update available?
     * @return true if ruby can be updated, false otherwise
     */
    public static boolean isRubyUpdateAvailable() {
        HttpURLConnection connection = null;
        BufferedReader reader = null;
        String line;
        boolean exitForError = true;
        Double localVersion = System.getLocalRubyVersion();

        try {
            synchronized (mRubyInfo) {
                if (mRubyInfo.version == null) {

                    HttpURLConnection.setFollowRedirects(true);
                    URL url = new URL(REMOTE_RUBY_VERSION_URL);
                    connection = (HttpURLConnection) url.openConnection();
                    connection.connect();

                    if (connection.getResponseCode() != 200)
                        return false;

                    reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
                    StringBuilder sb = new StringBuilder();

                    while ((line = reader.readLine()) != null) {
                        sb.append(line);
                    }

                    JSONObject info = new JSONObject(sb.toString());
                    mRubyInfo.url = info.getString("url");
                    mRubyInfo.version = info.getDouble("version");
                    mRubyInfo.versionString = String.format("%d", mRubyInfo.version.intValue());
                    mRubyInfo.path = String.format("%s/%s", System.getStoragePath(), info.getString("name"));
                    mRubyInfo.archiver = archiveAlgorithm.valueOf(info.getString("archiver"));
                    mRubyInfo.compression = compressionAlgorithm.valueOf(info.getString("compression"));
                    mRubyInfo.md5 = info.getString("md5");
                    mRubyInfo.sha1 = info.getString("sha1");
                    mRubyInfo.outputDir = System.getRubyPath();
                }
                exitForError = false;

                if (Shell.canExecuteInDir(mRubyInfo.outputDir)) {
                    mRubyInfo.executableOutputDir = mRubyInfo.outputDir;
                } else {
                    String realPath = Shell.getRealPath(mRubyInfo.outputDir);
                    if (Shell.canRootExecuteInDir(realPath))
                        mRubyInfo.executableOutputDir = realPath;
                    else {
                        Logger.error(String.format("cannot create executable files in '%s' or '%s'",
                                mRubyInfo.outputDir, realPath));
                        return false;
                    }
                }

                if (localVersion == null || localVersion < mRubyInfo.version)
                    return true;
            }
        } catch (Exception e) {
            System.errorLogging(e);
        } finally {
            try {
                if (reader != null)
                    reader.close();
            } catch (Exception e) {
                //ignored
            }
            if (connection != null)
                connection.disconnect();
            if (exitForError)
                mRubyInfo.reset();
        }
        return false;
    }

    public static boolean isGemUpdateAvailable() {

        try {
            synchronized (mGemUploadParser) {
                GemParser.RemoteGemInfo[] gemInfoArray = mGemUploadParser.parse();
                ArrayList<GemParser.RemoteGemInfo> gemsToUpdate = new ArrayList<GemParser.RemoteGemInfo>();

                if (gemInfoArray.length == 0)
                    return false;

                String format = String.format("%s/lib/ruby/gems/1.9.1/specifications/%%s-%%s-arm-linux.gemspec",
                        System.getRubyPath());

                for (GemParser.RemoteGemInfo gemInfo : gemInfoArray) {
                    File f = new File(String.format(format, gemInfo.name, gemInfo.version));
                    if (!f.exists() || f.lastModified() < gemInfo.uploaded.getTime()) {
                        Logger.debug(String.format("'%s' %s", f.getAbsolutePath(),
                                (f.exists() ? "is old" : "does not exists")));
                        gemsToUpdate.add(gemInfo);
                    }
                }

                if (gemsToUpdate.size() == 0)
                    return false;

                mGemUploadParser.setOldGems(gemsToUpdate.toArray(new GemParser.RemoteGemInfo[gemsToUpdate.size()]));
                return true;
            }
        } catch (IOException e) {
            Logger.warning(e.getClass() + ": " + e.getMessage());
        } catch (JSONException e) {
            System.errorLogging(e);
        }
        return false;
    }

    /**
     * is a MetaSploitFramework update available?
     * @return true if the framework can be updated, false otherwise
     */
    public static boolean isMsfUpdateAvailable() {
        boolean exitForError = true;
        String branch = System.getSettings().getString("MSF_BRANCH", "release");
        Double localVersion = System.getLocalMsfVersion();
        HashMap<Integer, String> msfModeMap = new HashMap<Integer, String>() {
            {
                put(0755, "msfrpcd msfconsole msfcli $(find . -name '*.rb')");
            }
        };

        try {
            String name = String.format(LOCAL_MSF_NAME, branch);
            String path = String.format("%s/%s", System.getStoragePath(), name);
            File local = new File(path);

            synchronized (mMsfInfo) {

                if (local.exists() && local.isFile() && local.canRead()) {
                    mMsfInfo.url = null;
                    mMsfInfo.version = (localVersion != null ? localVersion + 1 : 0);
                } else if (mMsfInfo.url == null) {
                    synchronized (mMsfRepoParser) {
                        if (!branch.equals(mMsfRepoParser.getBranch()))
                            mMsfRepoParser.setBranch(branch);
                        mMsfInfo.versionString = mMsfRepoParser.getLastCommitSha();
                    }
                    mMsfInfo.url = String.format(REMOTE_MSF_URL, branch);
                    // see System.getLocalMsfVersion for more info about this line of code
                    mMsfInfo.version = (new BigInteger(mMsfInfo.versionString.substring(0, 7), 16)).doubleValue();
                }

                mMsfInfo.name = name;
                mMsfInfo.path = path;
                mMsfInfo.outputDir = System.getMsfPath();
                mMsfInfo.archiver = archiveAlgorithm.zip;
                mMsfInfo.dirToExtract = "metasploit-framework-" + branch + "/";
                mMsfInfo.modeMap = msfModeMap;

                exitForError = false;

                if (Shell.canExecuteInDir(mMsfInfo.outputDir)) {
                    mMsfInfo.executableOutputDir = mMsfInfo.outputDir;
                } else {
                    String realPath = Shell.getRealPath(mMsfInfo.outputDir);
                    if (Shell.canRootExecuteInDir(realPath)) {
                        mMsfInfo.executableOutputDir = realPath;
                    } else {
                        Logger.error(String.format("cannot create executable files in '%s' or '%s'",
                                mMsfInfo.outputDir, realPath));
                        return false;
                    }
                }

                if (!mMsfInfo.version.equals(localVersion))
                    return true;
            }
        } catch (Exception e) {
            System.errorLogging(e);
        } finally {
            if (exitForError)
                mMsfInfo.reset();
        }
        return false;
    }

    public static String[] getMsfBranches() {
        synchronized (mMsfRepoParser) {
            try {
                return mMsfRepoParser.getBranches();
            } catch (JSONException e) {
                System.errorLogging(e);
            } catch (IOException e) {
                Logger.warning("getMsfBranches: " + e.getMessage());
            }
        }
        return new String[] { "master" };
    }

    /**
     * notify activities that some error occurred
     * @param message error message
     */
    private void sendError(int message) {
        Intent i = new Intent(ERROR);
        i.putExtra(MESSAGE, message);
        sendBroadcast(i);
    }

    /**
     * notify activities that we finished our job
     * @param a the performed action
     */
    private void sendDone(action a) {
        Intent i = new Intent(DONE);
        i.putExtra(ACTION, a);
        sendBroadcast(i);
    }

    /**
     * convert a byte array to it's hexadecimal string representation
     * @param digest the byte array to convert
     * @return the hexadecimal string that represent the given array
     */
    private String digest2string(byte[] digest) {
        StringBuilder sb = new StringBuilder();
        for (byte b : digest) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    /**
     * open a compressed InputStream
     * @param in the InputStream to decompress
     * @return the InputStream to read from
     * @throws IOException if an I/O error occurs
     */
    private InputStream openCompressedStream(InputStream in) throws IOException {
        if (mCurrentTask.compression == null)
            return in;
        switch (mCurrentTask.compression) {
        default:
        case none:
            return in;
        case gzip:
            return new GzipCompressorInputStream(in);
        case bzip:
            return new BZip2CompressorInputStream(in);
        case xz:
            return new XZCompressorInputStream(in);
        }
    }

    /**
     * open an Archive InputStream
     * @param in the InputStream to the archive
     * @return the ArchiveInputStream to read from
     * @throws IOException if an I/O error occurs
     * @throws java.lang.IllegalStateException if no archive method has been choose
     */
    private ArchiveInputStream openArchiveStream(InputStream in) throws IOException, IllegalStateException {
        switch (mCurrentTask.archiver) {
        case tar:
            return new TarArchiveInputStream(new BufferedInputStream(openCompressedStream(in)));
        case zip:
            return new ZipArchiveInputStream(new BufferedInputStream(openCompressedStream(in)));
        default:
            throw new IllegalStateException("trying to open an archive, but no archive algorithm selected.");
        }
    }

    /**
     * delete a directory recursively
     * @param f the file/directory to delete
     * @throws IOException if cannot delete something
     */
    private void deleteRecursively(File f) throws IOException {
        if (f.isDirectory()) {
            for (File c : f.listFiles())
                deleteRecursively(c);
        }
        if (!f.delete())
            throw new IOException("Failed to delete file: " + f);
    }

    /**
     * wipe the destination dir ( rm -rf )
     */
    private void wipe() {
        File outputFile;
        if (mCurrentTask == null || mCurrentTask.outputDir == null || mCurrentTask.outputDir.isEmpty()
                || !(outputFile = new File(mCurrentTask.outputDir)).exists())
            return;

        try {
            Shell.exec("rm -rf '" + mCurrentTask.outputDir + "'");
            return;
        } catch (Exception e) {
            System.errorLogging(e);
        }

        try {
            deleteRecursively(outputFile);
        } catch (IOException e) {
            System.errorLogging(e);
        }
    }

    /**
     * connect to the notification manager and create a notification builder.
     * it also setup the cancellation Intent for get notified when our notification got cancelled.
     */
    private void setupNotification() {
        // get notification manager
        mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        // get notification builder
        mBuilder = new NotificationCompat.Builder(this);
        // create a broadcast receiver to get actions
        // performed on the notification by the user
        mReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                String action = intent.getAction();
                if (action == null)
                    return;
                // user cancelled our notification
                if (action.equals(NOTIFICATION_CANCELLED)) {
                    mRunning = false;
                    stopSelf();
                }
            }
        };
        // register our receiver
        registerReceiver(mReceiver, new IntentFilter(NOTIFICATION_CANCELLED));
        // set common notification actions
        mBuilder.setDeleteIntent(
                PendingIntent.getBroadcast(this, CANCEL_CODE, new Intent(NOTIFICATION_CANCELLED), 0));
        mBuilder.setContentIntent(PendingIntent.getActivity(this, 0, new Intent(), 0));
    }

    /**
     * if mContentIntent is null delete our notification,
     * else assign it to the notification onClick
     */
    private void finishNotification() {
        if (mCurrentTask.contentIntent == null) {
            Logger.debug("deleting notifications");
            if (mNotificationManager != null)
                mNotificationManager.cancel(NOTIFICATION_ID);
        } else {
            Logger.debug("assign '" + mCurrentTask.contentIntent.toString() + "' to notification");
            if (mBuilder != null && mNotificationManager != null) {
                mBuilder.setContentIntent(
                        PendingIntent.getActivity(this, DOWNLOAD_COMPLETE_CODE, mCurrentTask.contentIntent, 0));
                mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
            }
        }
        if (mReceiver != null)
            unregisterReceiver(mReceiver);
        mReceiver = null;
        mBuilder = null;
        mNotificationManager = null;
    }

    /**
     * wait that a shell terminate or user cancel the notification.
     * @param shell the Thread returned by {@link it.evilsocket.dsploit.core.Shell#async(String, it.evilsocket.dsploit.core.Shell.OutputReceiver, boolean)}
     * @param cancellationMessage the message of the CancellationException
     * @throws java.io.IOException when cannot execute shell
     * @throws java.util.concurrent.CancellationException when user cancelled the notification
     */
    private int execShell(Thread shell, String cancellationMessage)
            throws IOException, CancellationException, InterruptedException {
        if (!(shell instanceof Shell.StreamGobbler))
            throw new IOException("cannot execute shell commands");
        shell.start();
        while (mRunning && shell.getState() != Thread.State.TERMINATED)
            Thread.sleep(10);
        if (!mRunning) {
            shell.interrupt();
            throw new CancellationException(cancellationMessage);
        } else
            shell.join();

        int ret = ((Shell.StreamGobbler) shell).exitValue;

        if (ret != 0 && mErrorOutput.length() > 0)
            for (String line : mErrorOutput.toString().split("\n"))
                if (line.length() > 0)
                    Logger.error(line);

        return ret;
    }

    /**
     * check if an archive is valid by reading it.
     * @throws RuntimeException if trying to run this with no archive
     */
    private void verifyArchiveIntegrity() throws RuntimeException, KeyException {
        File f;
        long total;
        short old_percentage, percentage;
        CountingInputStream counter;
        ArchiveInputStream is;
        byte[] buffer;
        boolean dirToExtractFound;

        Logger.info("verifying archive integrity");

        if (mCurrentTask == null || mCurrentTask.path == null)
            throw new RuntimeException("no archive to test");

        mBuilder.setContentTitle(getString(R.string.checking)).setSmallIcon(android.R.drawable.ic_popup_sync)
                .setContentText("").setProgress(100, 0, false);
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

        f = new File(mCurrentTask.path);
        try {
            counter = new CountingInputStream(new FileInputStream(f));
        } catch (FileNotFoundException e) {
            throw new RuntimeException(String.format("archive '%s' does not exists", mCurrentTask.path));
        }

        dirToExtractFound = mCurrentTask.dirToExtract == null;

        try {
            is = openArchiveStream(counter);
            ArchiveEntry entry;
            buffer = new byte[2048];
            total = f.length();
            old_percentage = -1;
            // consume the archive
            while (mRunning && (entry = is.getNextEntry()) != null)
                if (!dirToExtractFound && entry.getName().startsWith(mCurrentTask.dirToExtract))
                    dirToExtractFound = true;
            while (mRunning && is.read(buffer) > 0) {
                percentage = (short) (((double) counter.getBytesRead() / total) * 100);
                if (percentage != old_percentage) {
                    mBuilder.setProgress(100, percentage, false).setContentInfo(percentage + "%");
                    mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
                    old_percentage = percentage;
                }
            }
        } catch (IOException e) {
            throw new KeyException("corrupted archive: " + e.getMessage());
        }

        if (!mRunning)
            throw new CancellationException("archive integrity check cancelled");

        if (!dirToExtractFound)
            throw new KeyException(String.format("archive '%s' does not contains required '%s' directory",
                    mCurrentTask.path, mCurrentTask.dirToExtract));
    }

    /**
     * check if mLocalFile exists.
     *
     * @return true if file exists and match md5sum and sha1sum.
     * @throws java.util.concurrent.CancellationException when check is cancelled by user
     * @throws SecurityException bad file permissions
     * @throws IOException when IOException occurs
     * @throws java.security.NoSuchAlgorithmException when digests cannot be created
     * @throws java.security.KeyException when file checksum fails
     */
    private boolean haveLocalFile()
            throws CancellationException, SecurityException, IOException, NoSuchAlgorithmException, KeyException {

        File file = null;
        InputStream reader = null;
        boolean exitForError = true;

        if (mCurrentTask.path == null)
            return false;

        try {
            MessageDigest md5, sha1;
            byte[] buffer;
            int read;
            short percentage, previous_percentage;
            long read_counter, total;

            file = new File(mCurrentTask.path);
            buffer = new byte[4096];
            total = file.length();
            read_counter = 0;
            previous_percentage = -1;

            if (!file.exists() || !file.isFile())
                return false;

            if (!file.canWrite() || !file.canRead()) {
                read = -1;
                try {
                    read = Shell.exec(String.format("chmod 777 '%s'", mCurrentTask.path));
                } catch (Exception e) {
                    System.errorLogging(e);
                }
                if (read != 0)
                    throw new SecurityException(String.format("bad file permissions for '%s', chmod returned: %d",
                            mCurrentTask.path, read));
            }

            if (mCurrentTask.md5 != null || mCurrentTask.sha1 != null) {
                mBuilder.setContentTitle(getString(R.string.checking))
                        .setSmallIcon(android.R.drawable.ic_popup_sync).setContentText("")
                        .setProgress(100, 0, false);
                mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

                md5 = (mCurrentTask.md5 != null ? MessageDigest.getInstance("MD5") : null);
                sha1 = (mCurrentTask.sha1 != null ? MessageDigest.getInstance("SHA-1") : null);

                reader = new FileInputStream(file);
                while (mRunning && (read = reader.read(buffer)) != -1) {
                    if (md5 != null)
                        md5.update(buffer, 0, read);
                    if (sha1 != null)
                        sha1.update(buffer, 0, read);

                    read_counter += read;

                    percentage = (short) (((double) read_counter / total) * 100);
                    if (percentage != previous_percentage) {
                        mBuilder.setProgress(100, percentage, false).setContentInfo(percentage + "%");
                        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
                        previous_percentage = percentage;
                    }
                }
                reader.close();
                reader = null;
                if (!mRunning) {
                    exitForError = false;
                    throw new CancellationException("local file check cancelled");
                }
                if (md5 != null && !mCurrentTask.md5.equals(digest2string(md5.digest())))
                    throw new KeyException("wrong MD5");
                if (sha1 != null && !mCurrentTask.sha1.equals(digest2string(sha1.digest())))
                    throw new KeyException("wrong SHA-1");
                Logger.info(String.format("checksum ok: '%s'", mCurrentTask.path));
            } else if (mCurrentTask.archiver != null) {
                verifyArchiveIntegrity();
            }
            Logger.info(String.format("file already exists: '%s'", mCurrentTask.path));
            mBuilder.setSmallIcon(android.R.drawable.stat_sys_download_done)
                    .setContentTitle(getString(R.string.update_available))
                    .setContentText(getString(R.string.click_here_to_upgrade)).setProgress(0, 0, false) // remove progress bar
                    .setAutoCancel(true);
            exitForError = false;
            return true;
        } finally {
            if (exitForError && file != null && file.exists() && !file.delete())
                Logger.error(String.format("cannot delete local file '%s'", mCurrentTask.path));
            try {
                if (reader != null)
                    reader.close();
            } catch (IOException e) {
                System.errorLogging(e);
            }
        }
    }

    /**
     * download mCurrentTask.url to mCurrentTask.path
     *
     * @throws KeyException when MD5 or SHA1 sum fails
     * @throws IOException when IOError occurs
     * @throws NoSuchAlgorithmException when required digest algorithm is not available
     * @throws CancellationException when user cancelled the download via notification
     */
    private void downloadFile()
            throws SecurityException, KeyException, IOException, NoSuchAlgorithmException, CancellationException {
        if (mCurrentTask.url == null || mCurrentTask.path == null)
            return;

        File file = null;
        FileOutputStream writer = null;
        InputStream reader = null;
        HttpURLConnection connection = null;
        boolean exitForError = true;

        try {
            MessageDigest md5, sha1;
            URL url;
            byte[] buffer;
            int read;
            short percentage, previous_percentage;
            long downloaded, total;

            mBuilder.setContentTitle(getString(R.string.downloading_update))
                    .setContentText(getString(R.string.connecting))
                    .setSmallIcon(android.R.drawable.stat_sys_download).setProgress(100, 0, true);
            mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

            md5 = (mCurrentTask.md5 != null ? MessageDigest.getInstance("MD5") : null);
            sha1 = (mCurrentTask.sha1 != null ? MessageDigest.getInstance("SHA-1") : null);
            buffer = new byte[4096];
            file = new File(mCurrentTask.path);

            if (file.exists() && file.isFile())
                //noinspection ResultOfMethodCallIgnored
                file.delete();

            HttpURLConnection.setFollowRedirects(true);
            url = new URL(mCurrentTask.url);
            connection = (HttpURLConnection) url.openConnection();

            connection.connect();

            writer = new FileOutputStream(file);
            reader = connection.getInputStream();

            total = connection.getContentLength();
            read = connection.getResponseCode();

            if (read != 200)
                throw new IOException(
                        String.format("cannot download '%s': responseCode: %d", mCurrentTask.url, read));

            downloaded = 0;
            previous_percentage = -1;

            mBuilder.setContentText(file.getName());
            mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

            Logger.info(String.format("downloading '%s' to '%s'", mCurrentTask.url, mCurrentTask.path));

            while (mRunning && (read = reader.read(buffer)) != -1) {
                writer.write(buffer, 0, read);
                if (md5 != null)
                    md5.update(buffer, 0, read);
                if (sha1 != null)
                    sha1.update(buffer, 0, read);

                if (total >= 0) {
                    downloaded += read;

                    percentage = (short) (((double) downloaded / total) * 100);

                    if (percentage != previous_percentage) {
                        mBuilder.setProgress(100, percentage, false).setContentInfo(percentage + "%");
                        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
                        previous_percentage = percentage;
                    }
                }
            }

            if (!mRunning)
                throw new CancellationException("download cancelled");

            Logger.info("download finished successfully");

            if (md5 != null || sha1 != null) {
                if (md5 != null && !mCurrentTask.md5.equals(digest2string(md5.digest()))) {
                    throw new KeyException("wrong MD5");
                } else if (sha1 != null && !mCurrentTask.sha1.equals(digest2string(sha1.digest()))) {
                    throw new KeyException("wrong SHA-1");
                }
            } else if (mCurrentTask.archiver != null) {
                verifyArchiveIntegrity();
            }

            exitForError = false;

        } finally {
            if (exitForError && file != null && file.exists() && !file.delete())
                Logger.error(String.format("cannot delete file '%s'", mCurrentTask.path));
            try {
                if (writer != null)
                    writer.close();
                if (reader != null)
                    reader.close();
                if (connection != null)
                    connection.disconnect();
            } catch (IOException e) {
                System.errorLogging(e);
            }
        }
    }

    /**
     * extract an archive into a directory
     *
     * @throws IOException if some I/O error occurs
     * @throws java.util.concurrent.CancellationException if task is cancelled by user
     * @throws java.lang.InterruptedException when the the running thread get cancelled.
     */
    private void extract() throws CancellationException, RuntimeException, IOException, InterruptedException {
        ArchiveInputStream is = null;
        ArchiveEntry entry;
        CountingInputStream counter;
        File f, inFile;
        File[] list;
        String name;
        FileOutputStream fos = null;
        byte data[] = new byte[2048];
        int mode;
        int count;
        long total;
        short percentage, old_percentage;

        if (mCurrentTask.path == null || mCurrentTask.outputDir == null)
            return;

        mBuilder.setContentTitle(getString(R.string.extracting)).setContentText("").setContentInfo("")
                .setSmallIcon(android.R.drawable.ic_popup_sync).setProgress(100, 0, false);
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

        Logger.info(String.format("extracting '%s' to '%s'", mCurrentTask.path, mCurrentTask.outputDir));

        try {
            inFile = new File(mCurrentTask.path);
            total = inFile.length();
            counter = new CountingInputStream(new FileInputStream(inFile));
            is = openArchiveStream(counter);
            old_percentage = -1;

            f = new File(mCurrentTask.outputDir);
            if (f.exists() && f.isDirectory() && (list = f.listFiles()) != null && list.length > 2)
                wipe();

            if (is instanceof TarArchiveInputStream && mCurrentTask.modeMap == null)
                mCurrentTask.modeMap = new HashMap<Integer, String>();

            while (mRunning && (entry = is.getNextEntry()) != null) {
                name = entry.getName().replaceFirst("^\\./?", "");

                if (mCurrentTask.dirToExtract != null) {
                    if (!name.startsWith(mCurrentTask.dirToExtract))
                        continue;
                    else
                        name = name.substring(mCurrentTask.dirToExtract.length());
                }

                f = new File(mCurrentTask.outputDir, name);

                if (entry.isDirectory()) {
                    if (!f.exists()) {
                        if (!f.mkdirs()) {
                            throw new IOException(
                                    String.format("Couldn't create directory '%s'.", f.getAbsolutePath()));
                        }
                    }
                } else {
                    BufferedOutputStream bof = new BufferedOutputStream(new FileOutputStream(f));

                    while (mRunning && (count = is.read(data)) != -1) {
                        bof.write(data, 0, count);
                        percentage = (short) (((double) counter.getBytesRead() / total) * 100);
                        if (percentage != old_percentage) {
                            mBuilder.setProgress(100, percentage, false).setContentInfo(percentage + "%");
                            mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
                            old_percentage = percentage;
                        }
                    }
                    bof.flush();
                    bof.close();
                }
                // Zip does not store file permissions.
                if (entry instanceof TarArchiveEntry) {
                    mode = ((TarArchiveEntry) entry).getMode();

                    if (!mCurrentTask.modeMap.containsKey(mode))
                        mCurrentTask.modeMap.put(mode, entry.getName() + " ");
                    else
                        mCurrentTask.modeMap.put(mode,
                                mCurrentTask.modeMap.get(mode).concat(entry.getName() + " "));
                }
            }

            if (!mRunning)
                throw new CancellationException("extraction cancelled.");

            Logger.info("extraction completed");

            f = new File(mCurrentTask.outputDir, ".nomedia");
            if (f.createNewFile())
                Logger.info(".nomedia created");

            if (mCurrentTask.versionString != null && !mCurrentTask.versionString.isEmpty()) {
                f = new File(mCurrentTask.outputDir, "VERSION");
                fos = new FileOutputStream(f);
                fos.write(mCurrentTask.versionString.getBytes());
            } else
                Logger.warning("version string not found");

            mBuilder.setContentInfo("").setProgress(100, 100, true);
            mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
        } finally {
            if (is != null)
                is.close();
            if (fos != null)
                fos.close();
        }
    }

    /**
     * correct file modes on extracted files
     * @throws CancellationException if task get cancelled by user
     */
    private void correctModes() throws CancellationException, IOException, RuntimeException, InterruptedException {
        /*
         * NOTE:  this horrible solution to chmod is only
         *        a temporary way to changing mode to files.
         *        we have to run chmod as root, our android App
         *        does not usually have write permissions
         *        outside of it's data dir.
         *        we will make a better way to achieve that shortly
         *        by executing native code as root.
         */
        if (mCurrentTask.modeMap == null || mCurrentTask.modeMap.size() == 0)
            return;

        if (mCurrentTask.executableOutputDir == null)
            throw new IOException("output directory does not allow executable contents.");

        mBuilder.setContentTitle(getString(R.string.setting_file_modes)).setContentText("").setContentInfo("")
                .setSmallIcon(android.R.drawable.ic_popup_sync).setProgress(100, 0, true);
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

        StringBuilder sb = new StringBuilder();
        sb.append("cd '");
        sb.append(mCurrentTask.executableOutputDir);
        sb.append("' ");
        for (Map.Entry<Integer, String> e : mCurrentTask.modeMap.entrySet()) {
            sb.append(" && ");
            sb.append(String.format("chmod %o %s", e.getKey(), e.getValue()));
        }

        if (execShell(Shell.async(sb.toString(), mErrorReceiver), "chmod cancelled") != 0) {
            Logger.debug("chmod command: " + sb.toString());
            throw new IOException("cannot chmod extracted files.");
        }
    }

    /**
     * patch shebang on extracted files.
     * simply replace standard '/usr/bin/env' with busybox one
     * @throws IOException if cannot execute shell commands
     * @throws InterruptedException if current thread get interrupted
     * @throws RuntimeException if something goes wrong
     * @throws java.util.concurrent.CancellationException if user cancelled this task
     */
    private void patchShebang() throws IOException, InterruptedException, RuntimeException, CancellationException {

        if (mCurrentTask.outputDir == null)
            return;

        if (mCurrentTask.executableOutputDir == null)
            throw new IOException("output directory does not allow executable contents.");

        final StringBuilder envPath = new StringBuilder();

        mBuilder.setContentTitle(getString(R.string.patching_shebang)).setContentText("").setContentInfo("")
                .setSmallIcon(android.R.drawable.ic_popup_sync).setProgress(100, 0, true);
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

        if (Shell.exec("which env", new Shell.OutputReceiver() {
            @Override
            public void onStart(String command) {
            }

            @Override
            public void onNewLine(String line) {
                if (line.length() > 0) {
                    envPath.delete(0, envPath.length());
                    envPath.append(line);
                }
            }

            @Override
            public void onEnd(int exitCode) {
            }
        }) != 0)
            throw new RuntimeException("cannot find 'env' executable");

        Logger.debug("envPath: " + envPath);

        Thread shell = Shell
                .async(String.format("sed -i '1s,^#!/usr/bin/env,#!%s,' $(find '%s' -type f -perm +111 )",
                        envPath.toString(), mCurrentTask.executableOutputDir), mErrorReceiver);

        if (execShell(shell, "cancelled while changing shebangs") != 0)
            throw new RuntimeException("cannot change shebang");
    }

    /**
     * install gems required by the MSF
     */
    private void installGems() throws CancellationException, RuntimeException, IOException, InterruptedException {
        String msfPath = System.getMsfPath();

        mBuilder.setContentTitle(getString(R.string.installing_gems))
                .setContentText(getString(R.string.installing_bundle)).setContentInfo("")
                .setSmallIcon(android.R.drawable.stat_sys_download).setProgress(100, 0, true);
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
        Shell.setupRubyEnviron();
        Thread shell;

        shell = Shell.async("gem install bundle", mErrorReceiver);

        // install bundle gem, required for install msf
        if (execShell(shell, "cancelled while install bundle") != 0)
            throw new RuntimeException("cannot install bundle");

        mBuilder.setContentText(getString(R.string.installing_msf_gems));
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

        // append our REMOTE_GEM_SERVER to msf Gemfile sources.
        // we use an our gem server to provide cross compiled gems,
        // because android does not comes with a compiler.

        shell = Shell
                .async(String.format("sed -i \"/source 'https:\\/\\/rubygems.org'/a\\\nsource '%s'\" '%s/Gemfile'",
                        REMOTE_GEM_SERVER, msfPath), mErrorReceiver);

        if (execShell(shell, "cancelled while adding our gem server") != 0)
            throw new RuntimeException("cannot add our gem server");

        // i was able to cross compile pcaprub 0.10.0,
        // newer version use pcap version that are not used by android.

        shell = Shell.async(String.format("sed -i \"s/'pcaprub'\\$/'pcaprub', '0.10.0'/\" '%s/Gemfile'", msfPath),
                mErrorReceiver);

        if (execShell(shell, "cancelled while patching pcaprub version") != 0)
            throw new RuntimeException("cannot specify pcaprub version");

        shell = Shell.async(String.format("cd '%s' && bundle install --without development test", msfPath),
                mErrorReceiver);

        // install gem required by msf using bundle
        if (execShell(shell, "cancelled on bundle install") != 0)
            throw new RuntimeException("cannot install msf gems");
    }

    /**
     * update rubygems thus to correct an SSL certificate mismatch error.
     */
    private void updateRubyGems() throws CancellationException, IOException, InterruptedException {
        mBuilder.setContentTitle(getString(R.string.installing_gems))
                .setContentText(getString(R.string.updating_rubygem)).setContentInfo("")
                .setSmallIcon(android.R.drawable.stat_sys_download).setProgress(100, 0, true);
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
        Shell.setupRubyEnviron();
        Thread shell;

        shell = Shell.async("gem update --system --source 'http://rubygems.org/'", mErrorReceiver);

        if (execShell(shell, "cancelled on gem system update") != 0)
            throw new IOException("cannot update RubyGems");

        // rubygems update rewrite the shebang
        patchShebang();
    }

    private void updateGems() throws IOException, InterruptedException, CancellationException, RuntimeException,
            KeyException, NoSuchAlgorithmException {
        GemParser.RemoteGemInfo[] gemsToUpdate = mGemUploadParser.getGemsToUpdate();

        if (gemsToUpdate == null || gemsToUpdate.length == 0)
            return;

        Shell.setupRubyEnviron();
        String localFormat = String.format("%s/%%s", System.getStoragePath());
        String remoteFormat = String.format("%s/gems/%%s", REMOTE_GEM_SERVER);
        mCurrentTask.archiver = archiveAlgorithm.tar;

        Thread shell;

        for (GemParser.RemoteGemInfo gemInfo : gemsToUpdate) {

            String gemFilename = String.format("%s-%s-arm-linux.gem", gemInfo.name, gemInfo.version);

            mCurrentTask.url = String.format(remoteFormat, gemFilename);
            mCurrentTask.path = String.format(localFormat, gemFilename);
            if (!haveLocalFile())
                downloadFile();

            mBuilder.setContentTitle(getString(R.string.installing_gems)).setContentText(gemInfo.name)
                    .setContentInfo("").setSmallIcon(android.R.drawable.ic_popup_sync).setProgress(100, 0, true);
            mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());

            shell = Shell.async(String.format("gem uninstall --force -x -v '%s' '%s' && gem install -l '%s'",
                    gemInfo.version, gemInfo.name, mCurrentTask.path), mErrorReceiver);

            String cancelMessage = String.format("cancelled while updating '%s-%s'", gemInfo.name, gemInfo.version);

            if (execShell(shell, cancelMessage) != 0)
                throw new RuntimeException(String.format("cannot update '%s-%s'", gemInfo.name, gemInfo.version));

            if (!(new File(mCurrentTask.path).delete()))
                Logger.warning(String.format("cannot delete downloaded gem '%s'", mCurrentTask.path));
            mCurrentTask.path = null;
        }
    }

    private void clearGemsCache() {

        if (!System.getSettings().getBoolean("MSF_ENABLED", true))
            return;

        try {
            Shell.exec(String.format("rm -rf '%s/lib/ruby/gems/1.9.1/cache/'", System.getRubyPath()));
        } catch (Exception e) {
            System.errorLogging(e);
        }
    }

    private void deleteTemporaryFiles() {
        if (mCurrentTask.path == null || mCurrentTask.path.isEmpty())
            return;
        if (!(new File(mCurrentTask.path)).delete())
            Logger.error(String.format("cannot delete temporary file '%s'", mCurrentTask.path));
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        action what_to_do = (action) intent.getSerializableExtra(ACTION);
        boolean exitForError = true;

        if (what_to_do == null) {
            Logger.error("received null action");
            return;
        }

        mRunning = true;

        switch (what_to_do) {
        case apk_update:
            mCurrentTask = mApkInfo;
            break;
        case ruby_update:
            mCurrentTask = mRubyInfo;
            break;
        case msf_update:
            mCurrentTask = mMsfInfo;
            break;
        case gems_update:
            mCurrentTask = new ArchiveMetadata();
            break;
        }

        try {
            setupNotification();

            synchronized (mCurrentTask) {
                if (!haveLocalFile())
                    downloadFile();
                extract();
                correctModes();
                patchShebang();

                if (what_to_do == action.ruby_update)
                    updateRubyGems();
                else if (what_to_do == action.msf_update)
                    installGems();
                else if (what_to_do == action.gems_update)
                    updateGems();

                if (what_to_do != action.apk_update)
                    deleteTemporaryFiles();
            }
            exitForError = false;
            if (what_to_do == action.msf_update)
                System.updateLocalMsfVersion();
            if (what_to_do == action.ruby_update)
                System.updateLocalRubyVersion();
            sendDone(what_to_do);
        } catch (SecurityException e) {
            sendError(R.string.bad_permissions);
            Logger.warning(e.getClass().getName() + ": " + e.getMessage());
        } catch (KeyException e) {
            sendError(R.string.checksum_failed);
            Logger.warning(e.getClass().getName() + ": " + e.getMessage());
        } catch (NoSuchAlgorithmException e) {
            sendError(R.string.error_occured);
            System.errorLogging(e);
        } catch (CancellationException e) {
            Logger.warning(e.getClass().getName() + ": " + e.getMessage());
        } catch (IOException e) {
            sendError(R.string.error_occured);
            System.errorLogging(e);
        } catch (RuntimeException e) {
            sendError(R.string.error_occured);
            if (e.getClass() == NullPointerException.class)
                System.errorLogging(e);
            else
                Logger.error(e.getClass().getName() + ": " + e.getMessage());
        } catch (InterruptedException e) {
            sendError(R.string.error_occured);
            System.errorLogging(e);
        } finally {
            if (exitForError) {
                clearGemsCache();
                wipe();
            }
            stopSelf();
            mRunning = false;
        }
    }

    @Override
    public void onDestroy() {
        finishNotification();
        super.onDestroy();
    }
}