guru.benson.pinch.Pinch.java Source code

Java tutorial

Introduction

Here is the source code for guru.benson.pinch.Pinch.java

Source

/*
 * Copyright 2013 Carl Benson
 * Copyright 2014 Mattias Niiranen
 *
 * 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 guru.benson.pinch;

import java.io.BufferedInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.zip.CRC32;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import java.util.zip.ZipEntry;

import org.apache.http.client.methods.HttpHead;

/**
 * This class allows for single file downloads from a ZIP file stored on an HTTP server.
 */
public class Pinch {

    final public static String LOG_TAG = Pinch.class.getSimpleName();

    public interface ProgressListener {
        /**
         * @param progressTotal
         *     Number of bytes that have been downloaded.
         * @param progressDelta
         *     Number of bytes that have been downloaded since last update.
         * @param totalSize
         *     Total size in bytes.
         */
        public void onProgress(long progressTotal, long progressDelta, long totalSize);
    }

    private URL mUrl;
    private String mUserAgent;

    private short entriesCount;
    private short zipFileCommentLength;

    private int centralDirectorySize;
    private int centralDirectoryOffset;

    /**
     * Class constructor.
     *
     * @param url
     *     The URL pointing to the ZIP file on the HTTP server.
     */
    public Pinch(URL url) {
        this(url, null);
    }

    /**
     * Class constructor.
     *
     * @param url
     *     The URL pointing to the ZIP file on the HTTP server.
     * @param userAgent
     *     User-agent to be used together with the download request.
     */
    public Pinch(URL url, String userAgent) {
        mUrl = url;
        mUserAgent = userAgent;
    }

    private boolean setUrl(String url) {
        try {
            mUrl = new URL(url);
        } catch (MalformedURLException e) {
            return false;
        }
        return true;
    }

    /**
     * Handy log method.
     *
     * @param msg
     *     The message to print to debug log.
     */
    private static void log(String msg) {
        if (BuildConfig.DEBUG) {
            android.util.Log.d(LOG_TAG, msg);
        }
    }

    /**
     * Handy close method to avoid nestled try/catch blocks.
     *
     * @param c
     *     The object to close.
     */
    private static void close(Closeable c) {
        if (c != null) {
            try {
                c.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private HttpURLConnection openConnection() throws IOException {
        HttpURLConnection conn = (HttpURLConnection) mUrl.openConnection();
        if (mUserAgent != null) {
            conn.setRequestProperty("User-agent", mUserAgent);
        }
        return conn;
    }

    /**
     * Handy disconnect method to wrap null-check.
     *
     * @param c
     *     Connection to disconnect.
     */
    private static void disconnect(HttpURLConnection c) {
        if (c != null) {
            c.disconnect();
        }
    }

    /**
     * Read the content length for the ZIP file.
     *
     * @return The content length in bytes or -1 failed.
     */
    private int getHttpFileSize() {
        HttpURLConnection conn = null;
        int length = -1;
        try {
            conn = openConnection();
            conn.setRequestMethod(HttpHead.METHOD_NAME);
            conn.connect();

            // handle re-directs
            if (conn.getResponseCode() == HttpURLConnection.HTTP_MOVED_TEMP) {
                if (setUrl(conn.getHeaderField("Location"))) {
                    disconnect(conn);
                    length = getHttpFileSize();
                }
            } else {
                length = conn.getContentLength();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            disconnect(conn);
        }

        log("Content length is " + length + " bytes");

        return length;
    }

    /**
     * Searches for the ZIP central directory.
     *
     * @param length
     *     The content length of the file to search.
     *
     * @return {@code true} if central directory was found and parsed, otherwise {@code false}
     */
    private boolean findCentralDirectory(int length) {
        HttpURLConnection conn = null;
        InputStream bis = null;

        long start = length - 4096;
        long end = length - 1;
        byte[] data = new byte[2048];

        try {
            conn = openConnection();
            conn.setRequestProperty("Range", "bytes=" + start + "-" + end);
            conn.setInstanceFollowRedirects(true);
            conn.connect();

            int responseCode = conn.getResponseCode();
            if (responseCode != HttpURLConnection.HTTP_PARTIAL) {
                throw new IOException("Unexpected HTTP server response: " + responseCode);
            }

            bis = conn.getInputStream();
            int read, bytes = 0;
            while ((read = bis.read(data)) != -1) {
                bytes += read;
            }

            log("Read " + bytes + " bytes");

        } catch (IOException e) {
            e.printStackTrace();
            return false;
        } finally {
            close(bis);
            disconnect(conn);
        }

        return parseEndOfCentralDirectory(data);
    }

    /**
     * Parses the ZIP central directory from a byte buffer.
     *
     * @param data
     *     The byte buffer to be parsed.
     *
     * @return {@code true} if central directory was parsed, otherwise {@code false}
     */
    private boolean parseEndOfCentralDirectory(byte[] data) {

        byte[] zipEndSignature = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN)
                .putInt((int) ZipConstants.ENDSIG).array();

        // find start of ZIP archive signature.
        int index = KMPMatch.indexOf(data, zipEndSignature);

        if (index < 0) {
            return false;
        }

        // wrap our head around a part of the buffer starting with index.
        ByteBuffer buf = ByteBuffer.wrap(data, index, data.length - index);
        buf.order(ByteOrder.LITTLE_ENDIAN);

        /*
         * For ZIP header descriptions, see
         * http://en.wikipedia.org/wiki/ZIP_(file_format)#File_headers
         */

        // skip signature
        buf.getInt();
        // skip numberOfThisDisk
        buf.getShort();
        // skip diskWhereCentralDirectoryStarts =
        buf.getShort();
        // skip numberOfCentralDirectoriesOnThisDisk
        buf.getShort();

        entriesCount = buf.getShort();
        centralDirectorySize = buf.getInt();
        centralDirectoryOffset = buf.getInt();
        zipFileCommentLength = buf.getShort();

        return true;
    }

    /**
     * Extract all ZipEntries from the ZIP central directory.
     *
     * @param buf
     *     The byte buffer containing the ZIP central directory.
     *
     * @return A list with all ZipEntries.
     */
    private static ArrayList<ExtendedZipEntry> parseHeaders(ByteBuffer buf) {
        ArrayList<ExtendedZipEntry> zeList = new ArrayList<ExtendedZipEntry>();

        buf.order(ByteOrder.LITTLE_ENDIAN);

        int offset = 0;

        while (offset < buf.limit() - ZipConstants.CENHDR) {
            short fileNameLen = buf.getShort(offset + ZipConstants.CENNAM);
            short extraFieldLen = buf.getShort(offset + ZipConstants.CENEXT);
            short fileCommentLen = buf.getShort(offset + ZipConstants.CENCOM);

            String fileName = new String(buf.array(), offset + ZipConstants.CENHDR, fileNameLen);

            ExtendedZipEntry zeGermans = new ExtendedZipEntry(fileName);

            zeGermans.setMethod(buf.getShort(offset + ZipConstants.CENHOW));

            CRC32 crc = new CRC32();
            crc.update(buf.getInt(offset + ZipConstants.CENCRC));
            zeGermans.setCrc(crc.getValue());

            zeGermans.setCompressedSize(buf.getInt(offset + ZipConstants.CENSIZ));
            zeGermans.setSize(buf.getInt(offset + ZipConstants.CENLEN));
            zeGermans.setInternalAttr(buf.getShort(offset + ZipConstants.CENATT));
            zeGermans.setExternalAttr(buf.getShort(offset + ZipConstants.CENATX));
            zeGermans.setOffset((long) buf.getInt(offset + ZipConstants.CENOFF));

            zeGermans.setExtraLength(extraFieldLen);

            zeList.add(zeGermans);
            offset += ZipConstants.CENHDR + fileNameLen + extraFieldLen + fileCommentLen;
        }

        return zeList;
    }

    /**
     * Gets the list of files included in the ZIP stored on the HTTP server.
     *
     * @return A list of ZIP entries.
     */
    public ArrayList<ExtendedZipEntry> parseCentralDirectory() {
        int contentLength = getHttpFileSize();
        if (contentLength <= 0) {
            return null;
        }

        if (!findCentralDirectory(contentLength)) {
            return null;
        }

        HttpURLConnection conn = null;
        InputStream bis = null;

        long start = centralDirectoryOffset;
        long end = start + centralDirectorySize - 1;

        byte[] data = new byte[2048];
        ByteBuffer buf = ByteBuffer.allocate(centralDirectorySize);

        try {
            conn = openConnection();
            conn.setRequestProperty("Range", "bytes=" + start + "-" + end);
            conn.setInstanceFollowRedirects(true);
            conn.connect();

            int responseCode = conn.getResponseCode();
            if (responseCode != HttpURLConnection.HTTP_PARTIAL) {
                throw new IOException("Unexpected HTTP server response: " + responseCode);
            }

            bis = conn.getInputStream();
            int read, bytes = 0;
            while ((read = bis.read(data)) != -1) {
                buf.put(data, 0, read);
                bytes += read;
            }

            log("Central directory is " + bytes + " bytes");

        } catch (IOException e) {
            e.printStackTrace();
            return null;
        } finally {
            close(bis);
            disconnect(conn);
        }

        return parseHeaders(buf);
    }

    /**
     * Wrapper method for {@link #downloadFile(ExtendedZipEntry, String)} where {@code name} is
     * extracted from {@code entry}.
     */
    public void downloadFile(ExtendedZipEntry entry) throws IOException, InterruptedException {
        downloadFile(entry, null, entry.getName(), null);
    }

    public void downloadFile(ExtendedZipEntry entry, String dir) throws IOException, InterruptedException {
        downloadFile(entry, dir, entry.getName(), null);
    }

    /**
     * Wrapper method for {@link #downloadFile(ExtendedZipEntry, String, String,
     * guru.benson.pinch.Pinch.ProgressListener)} where {@code name} is extracted from {@code
     * entry}.
     */
    public void downloadFile(ExtendedZipEntry entry, String dir, ProgressListener listener)
            throws IOException, InterruptedException {
        downloadFile(entry, dir, entry.getName(), listener);
    }

    /**
     * Download and inflate file from a ZIP stored on a HTTP server.
     *
     * @param entry
     *     Entry representing file to download.
     * @param name
     *     Path where to store the downloaded file.
     * @param listener
     *
     * @throws IOException
     *     If an error occurred while reading from network or writing to disk.
     * @throws InterruptedException
     *     If the thread was interrupted.
     */
    public void downloadFile(ExtendedZipEntry entry, String dir, String name, ProgressListener listener)
            throws IOException, InterruptedException {
        HttpURLConnection conn = null;
        InputStream is = null;
        FileOutputStream fos = null;

        try {
            File outFile = new File(dir != null ? dir + File.separator + name : name);

            if (!outFile.exists()) {
                if (outFile.getParentFile() != null) {
                    outFile.getParentFile().mkdirs();
                }
            }

            // no need to download 0 byte size directories
            if (entry.isDirectory()) {
                return;
            }

            fos = new FileOutputStream(outFile);

            byte[] buf = new byte[2048];
            int read, bytes = 0;

            conn = getEntryInputStream(entry);

            // this is a stored (non-deflated) file, read it raw without inflating it
            if (entry.getMethod() == ZipEntry.STORED) {
                is = new BufferedInputStream(conn.getInputStream());
            } else {
                is = new InflaterInputStream(conn.getInputStream(), new Inflater(true));
            }

            long totalSize = entry.getSize();
            while ((read = is.read(buf)) != -1) {
                if (Thread.currentThread().isInterrupted()) {
                    throw new InterruptedException("Download was interrupted");
                }
                // Ignore any extra data
                if (totalSize < read + bytes) {
                    read = ((int) totalSize) - bytes;
                }

                fos.write(buf, 0, read);
                bytes += read;
                if (listener != null) {
                    listener.onProgress(bytes, read, totalSize);
                }
            }

            log("Wrote " + bytes + " bytes to " + name);
        } finally {
            close(fos);
            close(is);
            disconnect(conn);
        }
    }

    /**
     * Get a {@link java.net.HttpURLConnection} that has its {@link java.io.InputStream} pointing at
     * the file data of the given {@link guru.benson.pinch.ExtendedZipEntry}.
     *
     * @throws IOException
     */
    private HttpURLConnection getEntryInputStream(ExtendedZipEntry entry) throws IOException {
        HttpURLConnection conn;
        InputStream is;

        // Define the local header range
        long start = entry.getOffset();
        long end = start + ZipConstants.LOCHDR;

        conn = openConnection();
        conn.setRequestProperty("Range", "bytes=" + start + "-" + end);
        conn.setInstanceFollowRedirects(true);
        conn.connect();

        int responseCode = conn.getResponseCode();
        if (responseCode != HttpURLConnection.HTTP_PARTIAL) {
            throw new IOException("Unexpected HTTP server response: " + responseCode);
        }

        byte[] dataBuffer = new byte[2048];
        int read, bytes = 0;

        is = conn.getInputStream();
        while ((read = is.read(dataBuffer)) != -1) {
            bytes += read;
        }
        close(is);
        disconnect(conn);
        if (bytes < ZipConstants.LOCHDR) {
            throw new IOException("Unable to fetch the local header");
        }

        ByteBuffer buffer = ByteBuffer.allocate(ZipConstants.LOCHDR);
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        buffer.put(dataBuffer, 0, ZipConstants.LOCHDR);

        final int headerSignature = buffer.getInt(0);
        if (headerSignature != 0x04034b50) {
            disconnect(conn);
            throw new IOException("Local file header signature mismatch");
        }

        final int localCompressedSize = buffer.getInt(ZipConstants.LOCSIZ);
        final short localFileNameLength = buffer.getShort(ZipConstants.LOCNAM);
        final short localExtraLength = buffer.getShort(ZipConstants.LOCEXT);

        // Define the local file range
        start = entry.getOffset() + ZipConstants.LOCHDR + localFileNameLength + localExtraLength;
        end = start + localCompressedSize;

        // Open a new one with
        conn = openConnection();
        conn.setRequestProperty("Range", "bytes=" + start + "-" + end);
        conn.setInstanceFollowRedirects(true);
        conn.connect();

        responseCode = conn.getResponseCode();
        if (responseCode != HttpURLConnection.HTTP_PARTIAL) {
            disconnect(conn);
            close(is);
            throw new IOException("Unexpected HTTP server response: " + responseCode);
        }

        return conn;
    }
}