net.rptools.lib.io.PackedFile.java Source code

Java tutorial

Introduction

Here is the source code for net.rptools.lib.io.PackedFile.java

Source

/*
 * 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 net.rptools.lib.io;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import net.rptools.lib.CodeTimer;
import net.rptools.lib.FileUtil;
import net.rptools.lib.GUID;
import net.rptools.lib.ModelVersionManager;

import org.apache.commons.io.IOUtils;
import org.apache.log4j.Logger;

import com.thoughtworks.xstream.XStream;

/**
 * Represents a container of content/files within a single actual file.
 * <p>
 * A packed file contains three parts:
 * <ul>
 * <li>Contents - A single object that represents the core content of the packed file, as a convenience method
 * <li>Properties - A String to Object map of arbitrary properties that can be used to describe the packed file
 * <li>Files - Any arbitrary files/data that are packed within the packed file
 * </ul>
 * <p>
 * The implementation uses two {@link Set}s, <b>addedFileSet</b> and <b>removedFileSet</b>, to keep track of paths for
 * content that has been added or removed from the packed file, respectively. This is because the actual file itself
 * isn't written until the {@link #save()} method is called, yet the application may want to dynamically add and remove
 * paths to the packed file and query the state of which files are currently included or excluded from the packed file.
 * <p>
 * In addition, the API allows for storing multiple types of objects into the packed file. The easiest to understand are
 * byte streams as they are binary values that are not modified by character set encoding during output. They are
 * represented by an array of bytes or an {@link InputStream}.
 * <p>
 * The second type of data is the file, represented here as a URL as it is more universally applicable (although
 * currently it is unused outside this class). URLs have their content retrieved from the source and are currently
 * written into the packed file without any character encoding (it is possible that the <code>Content-Type</code> of the
 * data stream could provide information on how the data should be written so this may change in the future).
 * <p>
 * The last and most important type of data is the POJO (plain old Java object). These are converted into XML using the
 * XStream library from codehaus.org. As the data is written to the output file it is character set encoded to UTF-8. It
 * is hoped that this solves the localization issues with saved macros and other data not being restored properly.
 * Because of this, data loaded from a packed file is always retrieved without character set encoding <b>unless</b> it
 * is XML data. This should preserve binary data such as JPEG and PNG images properly. A side effect of this is that all
 * character data should be written to the packed file as POJOs in order to obtain the automatic character set encoding.
 * (Otherwise, strings can be converted to UTF-8 using the {@link String#getBytes(String)} method.
 */
public class PackedFile {

    private static final String PROPERTY_FILE = "properties.xml";
    private static final String CONTENT_FILE = "content.xml";

    private static final Logger log = Logger.getLogger(PackedFile.class);

    private static File tmpDir = new File(System.getProperty("java.io.tmpdir")); // Shared temporary directory

    private final XStream xstream = new XStream();

    private final File file; // Original zip file
    private final File tmpFile; // Temporary directory where changes are kept

    private boolean dirty;
    private boolean propsLoaded;

    private Map<String, Object> propertyMap = new HashMap<String, Object>();
    private final Set<String> addedFileSet = new HashSet<String>();
    private final Set<String> removedFileSet = new HashSet<String>();

    private ModelVersionManager versionManager;

    /**
     * By default all temporary files are handled in /tmp. Use this method to globally set the location of the temporary
     * directory
     */
    public static void init(File tmpDir) {
        PackedFile.tmpDir = tmpDir;
    }

    public static File getTmpDir() {
        return PackedFile.tmpDir;
    }

    public void setModelVersionManager(ModelVersionManager versionManager) {
        this.versionManager = versionManager;
    }

    /**
     * Useful for configuring the xstream for object serialization
     */
    public XStream getXStream() {
        return xstream;
    }

    public PackedFile(File file) {
        this.file = file;
        dirty = !file.exists();
        tmpFile = new File(tmpDir, new GUID() + ".tmp");
    }

    /**
     * Retrieves the property map from the campaign file and accesses the given key, returning an Object that the key
     * holds. The Object is constructed from the XML content and could be anything.
     * 
     * @param key
     *            key for accessing the property map
     * @return the value (typically a String)
     * @throws IOException
     */
    public Object getProperty(String key) throws IOException {
        return getPropertyMap().get(key);
    }

    /**
     * Returns a list of all keys in the campaign's property map. See also {@link #getProperty(String)}.
     * 
     * @return list of all keys
     * @throws IOException
     */
    public Iterator<String> getPropertyNames() throws IOException {
        return getPropertyMap().keySet().iterator();
    }

    /**
     * Stores a new key/value pair into the property map. Existing keys are overwritten.
     * 
     * @param key
     * @param value
     *            any POJO; will be serialized into XML upon writing
     * @return the previous value for the given key
     * @throws IOException
     */
    public Object setProperty(String key, Object value) throws IOException {
        dirty = true;
        return getPropertyMap().put(key, value);
    }

    /**
     * Remove the property with the associated key from the property map.
     * 
     * @param key
     * @return the previous value for the given key
     * @throws IOException
     */
    public Object removeProperty(String key) throws IOException {
        dirty = true;
        return getPropertyMap().remove(key);
    }

    /**
     * Retrieves the contents of the <code>CONTENT_FILE</code> as a POJO. This object is the top-level data structure
     * for all information regarding the content of the PackedFile.
     * 
     * @return the results of the deserialization
     * @throws IOException
     */
    public Object getContent() throws IOException {
        return getContent(versionManager, (String) getProperty("version"));
    }

    /**
     * Same as {@link #getContent()} except that the version can be specified. This allows a newer release of an
     * application to provide automatic transformation information that will be applied to the XML as the object is
     * deserialized. The default transformation manager is used. (Think of the transformation as a simplified XSTL
     * process.)
     * 
     * @param fileVersion
     *            such as "1.3.70"
     * @return the results of the deserialization
     * @throws IOException
     */
    public Object getContent(String fileVersion) throws IOException {
        return getContent(versionManager, fileVersion);
    }

    /**
     * Same as {@link #getContent(String)} except that the transformation manager, <code>versionManager</code>, is
     * specified as a parameter.
     * 
     * @param versionManager
     *            which set of transforms to apply to older file versions
     * @param fileVersion
     *            such as "1.3.70"
     * @return the results of the deserialization
     * @throws IOException
     */
    public Object getContent(ModelVersionManager versionManager, String fileVersion) throws IOException {
        Reader r = null;
        try {
            if (versionManager != null && versionManager.isTransformationRequired(fileVersion)) {
                r = getFileAsReader(CONTENT_FILE);
                String xml = IOUtils.toString(r);
                xml = versionManager.transform(xml, fileVersion);
                xstream.ignoreUnknownElements(); // Jamz: Should we use this? This will ignore new classes/fields added.
                return xstream.fromXML(xml);
            } else {
                return getFileObject(CONTENT_FILE);
            }
        } catch (NullPointerException npe) {
            log.error("Problem finding/converting content file", npe);
            return null;
        } finally {
            IOUtils.closeQuietly(r);
        }
    }

    @SuppressWarnings("unchecked")
    protected Map<String, Object> getPropertyMap() throws IOException {
        if (hasFile(PROPERTY_FILE) && !propsLoaded) {
            propertyMap = null;
            // This is the case when we're pointing to a file but haven't loaded it yet
            try {
                Object obj = getFileObject(PROPERTY_FILE);
                if (obj instanceof Map<?, ?>) {
                    propertyMap = (Map<String, Object>) obj;
                    propsLoaded = true;
                } else
                    log.error("Unexpected class type for property object: " + obj.getClass().getName());
            } catch (NullPointerException npe) {
                log.error("Problem finding/converting property file", npe);
            }
        }
        return propertyMap;
    }

    public boolean isDirty() {
        return dirty;
    }

    public void save() throws IOException {
        CodeTimer saveTimer;

        if (!dirty) {
            return;
        }
        saveTimer = new CodeTimer("PackedFile.save");
        saveTimer.setEnabled(log.isDebugEnabled());

        InputStream is = null;

        // Create the new file
        File newFile = new File(tmpDir, new GUID() + ".pak");
        ZipOutputStream zout = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(newFile)));
        zout.setLevel(Deflater.BEST_COMPRESSION); // fast compression
        try {
            saveTimer.start(CONTENT_FILE);
            if (hasFile(CONTENT_FILE)) {
                zout.putNextEntry(new ZipEntry(CONTENT_FILE));
                is = getFileAsInputStream(CONTENT_FILE); // When copying, always use an InputStream
                IOUtils.copy(is, zout);
                IOUtils.closeQuietly(is);
                zout.closeEntry();
            }
            saveTimer.stop(CONTENT_FILE);

            saveTimer.start(PROPERTY_FILE);
            if (getPropertyMap().isEmpty()) {
                removeFile(PROPERTY_FILE);
            } else {
                zout.putNextEntry(new ZipEntry(PROPERTY_FILE));
                xstream.toXML(getPropertyMap(), zout);
                zout.closeEntry();
            }
            saveTimer.stop(PROPERTY_FILE);

            // Now put each file
            saveTimer.start("addFiles");
            addedFileSet.remove(CONTENT_FILE);
            for (String path : addedFileSet) {
                zout.putNextEntry(new ZipEntry(path));
                is = getFileAsInputStream(path); // When copying, always use an InputStream
                IOUtils.copy(is, zout);
                IOUtils.closeQuietly(is);
                zout.closeEntry();
            }
            saveTimer.stop("addFiles");

            // Copy the rest of the zip entries over
            saveTimer.start("copyFiles");
            if (file.exists()) {
                Enumeration<? extends ZipEntry> entries = zFile.entries();
                while (entries.hasMoreElements()) {
                    ZipEntry entry = entries.nextElement();
                    if (!entry.isDirectory() && !addedFileSet.contains(entry.getName())
                            && !removedFileSet.contains(entry.getName()) && !CONTENT_FILE.equals(entry.getName())
                            && !PROPERTY_FILE.equals(entry.getName())) {
                        //                  if (entry.getName().endsWith(".png") ||
                        //                        entry.getName().endsWith(".gif") ||
                        //                        entry.getName().endsWith(".jpeg"))
                        //                     zout.setLevel(Deflater.NO_COMPRESSION); // none needed for images as they are already compressed
                        //                  else
                        //                     zout.setLevel(Deflater.BEST_COMPRESSION); // fast compression
                        zout.putNextEntry(entry);
                        is = getFileAsInputStream(entry.getName()); // When copying, always use an InputStream
                        IOUtils.copy(is, zout);
                        IOUtils.closeQuietly(is);
                        zout.closeEntry();
                    } else if (entry.isDirectory()) {
                        zout.putNextEntry(entry);
                        zout.closeEntry();
                    }
                }
            }
            try {
                if (zFile != null)
                    zFile.close();
            } catch (IOException e) {
                // ignore close exception
            }
            zFile = null;
            saveTimer.stop("copyFiles");

            saveTimer.start("close");
            IOUtils.closeQuietly(zout);
            zout = null;
            saveTimer.stop("close");

            // Backup the original
            saveTimer.start("backup");
            File backupFile = new File(tmpDir, new GUID() + ".mv");
            if (file.exists()) {
                backupFile.delete(); // Always delete the old backup file first; renameTo() is very platform-dependent
                if (!file.renameTo(backupFile)) {
                    saveTimer.start("backup file");
                    FileUtil.copyFile(file, backupFile);
                    file.delete();
                    saveTimer.stop("backup file");
                }
            }
            saveTimer.stop("backup");

            saveTimer.start("finalize");
            // Finalize
            if (!newFile.renameTo(file)) {
                saveTimer.start("backup newFile");
                FileUtil.copyFile(newFile, file);
                saveTimer.stop("backup newFile");
            }
            if (backupFile.exists())
                backupFile.delete();
            saveTimer.stop("finalize");

            dirty = false;
        } finally {
            saveTimer.start("cleanup");
            try {
                if (zFile != null)
                    zFile.close();
            } catch (IOException e) {
                // ignore close exception
            }
            if (newFile.exists())
                newFile.delete();
            IOUtils.closeQuietly(is);
            IOUtils.closeQuietly(zout);
            saveTimer.stop("cleanup");

            if (log.isDebugEnabled())
                log.debug(saveTimer);
            saveTimer = null;
        }
    }

    /**
     * Set the given object as the information to write to the 'content.xml' file in the archive.
     * 
     * @param content
     * @throws IOException
     */
    public void setContent(Object content) throws IOException {
        putFile(CONTENT_FILE, content);
    }

    /**
     * Does the work of preparing for output to a temporary file, returning the {@link File} object associated with the
     * temporary location. The caller is then expected to open and write their data to the file which will later be
     * added to the ZIP file.
     * 
     * @param path
     *            path within the ZIP to write to
     * @return the <code>File</code> object for the temporary location
     * @throws IOException
     */
    private File putFileImpl(String path) throws IOException {
        if (!tmpFile.exists())
            tmpFile.getParentFile().mkdirs();

        // Have to store it in the exploded area since we can't directly save it to the zip
        File explodedFile = getExplodedFile(path);
        if (explodedFile.exists()) {
            explodedFile.delete();
        } else {
            explodedFile.getParentFile().mkdirs();
        }

        // We just remember that we added it, then go look for it later...
        addedFileSet.add(path);
        removedFileSet.remove(path);
        dirty = true;
        return explodedFile;
    }

    /**
     * Write the <code>byte</code> data to the given path in the ZIP file; as the data is binary there is no
     * {@link Charset} conversion.
     * 
     * @param path
     *            location within the ZIP file
     * @param data
     *            the binary data to be written
     * @throws IOException
     */
    public void putFile(String path, byte[] data) throws IOException {
        InputStream is = new ByteArrayInputStream(data);
        putFile(path, is);
        IOUtils.closeQuietly(is);
    }

    /**
     * Write the <b>binary</b> data to the given path in the ZIP file; as the data is presumed to be binary there is no
     * charset conversion.
     * 
     * @param path
     *            location within the ZIP file
     * @param data
     *            the binary data to be written in the form of an InputStream
     * @throws IOException
     */
    public void putFile(String path, InputStream is) throws IOException {
        File explodedFile = putFileImpl(path);
        FileOutputStream fos = new FileOutputStream(explodedFile);
        IOUtils.copy(is, fos);
        IOUtils.closeQuietly(fos);
    }

    /**
     * Write the serialized object to the given path in the ZIP file; as the data is an object it is first converted to
     * XML and character set encoding will take place as the data is written to the (temporary) file.
     * 
     * @param path
     *            location within the ZIP file
     * @param obj
     *            the object to be written
     * @throws IOException
     */
    public void putFile(String path, Object obj) throws IOException {
        File explodedFile = putFileImpl(path);
        FileOutputStream fos = new FileOutputStream(explodedFile);
        OutputStreamWriter osw = new OutputStreamWriter(fos, "UTF-8");
        BufferedWriter bw = new BufferedWriter(osw);

        xstream.toXML(obj, bw);

        bw.newLine(); // Not necessary but editing the file looks nicer. ;-)
        IOUtils.closeQuietly(bw);
    }

    /**
     * Write the data from the given URL to the path in the ZIP file; as the data is presumed binary there is no
     * {@link Charset} conversion.
     * <p>
     * FIXME Should the MIME type of the InputStream be checked??
     * 
     * @param path
     *            location within the ZIP file
     * @param data
     *            the binary data to be written
     * @throws IOException
     */
    public void putFile(String path, URL url) throws IOException {
        InputStream is = url.openStream();
        putFile(path, is);
        IOUtils.closeQuietly(is);
    }

    public boolean hasFile(String path) throws IOException {
        if (removedFileSet.contains(path))
            return false;

        File explodedFile = getExplodedFile(path);
        if (explodedFile.exists())
            return true;

        boolean ret = false;
        if (file.exists()) {
            ZipFile zipFile = getZipFile();
            ZipEntry ze = zipFile.getEntry(path);
            ret = (ze != null);
        }
        return ret;
    }

    private ZipFile zFile = null;

    private ZipFile getZipFile() throws IOException {
        if (zFile == null)
            zFile = new ZipFile(file);
        return zFile;
    }

    /**
     * Returns a POJO by reading the contents of the zip archive path specified and converting the XML via the
     * associated XStream object. (Because the XML is character data, this routine calls
     * {@link #getFileAsReader(String)} to handle character encoding.)
     * <p>
     * <b>TODO:</b> add {@link ModelVersionManager} support
     * 
     * @param path
     *            zip file archive path entry
     * @return Object created by translating the XML
     * @throws IOException
     */
    public Object getFileObject(String path) throws IOException {
        // This next line really should be routed thru the version manager...
        // Update:  a new XStreamConverter was created for the Asset object that
        // never marshalls the image data, but *does* unmarshall it.  This allows
        // older pre-1.3.b64 campaigns to be loaded but only the newer format
        // (with a separate image file) works on output.
        LineNumberReader r = getFileAsReader(path);
        Object o = null;
        try {
            xstream.ignoreUnknownElements(); // Jamz: Should we use this? This will ignore new classes/fields added.      
            o = xstream.fromXML(r);
        } catch (InstantiationError ie) {
            log.error("Found at line number " + r.getLineNumber());
            log.error("Cannot convert XML to Object", ie);
            throw ie;
        } finally {
            IOUtils.closeQuietly(r);
        }
        return o;
    }

    /**
     * Returns an InputStreamReader that corresponds to the zip file path specified. This method should be called only
     * for character-based file contents such as the <b>CONTENT_FILE</b> and <b>PROPERTY_FILE</b>. For binary data, such
     * as images (assets and thumbnails) use {@link #getFileAsInputStream(String)} instead.
     * 
     * @param path
     *            zip file archive path entry
     * @return Reader representing the data stream
     * @throws IOException
     */
    public LineNumberReader getFileAsReader(String path) throws IOException {
        File explodedFile = getExplodedFile(path);
        if ((!file.exists() && !tmpFile.exists() && !explodedFile.exists()) || removedFileSet.contains(path))
            throw new FileNotFoundException(path);
        if (explodedFile.exists())
            return new LineNumberReader(FileUtil.getFileAsReader(explodedFile));

        ZipEntry entry = new ZipEntry(path);
        ZipFile zipFile = getZipFile();
        InputStream in = null;
        try {
            in = new BufferedInputStream(zipFile.getInputStream(entry));
            if (log.isDebugEnabled()) {
                String type;
                type = FileUtil.getContentType(in);
                if (type == null)
                    type = FileUtil.getContentType(explodedFile);
                log.debug("FileUtil.getContentType() returned " + (type != null ? type : "(null)"));
            }
            return new LineNumberReader(new InputStreamReader(in, "UTF-8"));
        } catch (IOException ex) {
            // Don't need to close 'in' since zipFile.close() will do so
            throw ex;
        }
    }

    /**
     * Returns an InputStream that corresponds to the zip file path specified. This method should be called only for
     * binary file contents such as images (assets and thumbnails). For character-based data, use
     * {@link #getFileAsReader(String)} instead.
     * 
     * @param path
     *            zip file archive path entry
     * @return InputStream representing the data stream
     * @throws IOException
     */
    public InputStream getFileAsInputStream(String path) throws IOException {
        File explodedFile = getExplodedFile(path);
        if ((!file.exists() && !tmpFile.exists() && !explodedFile.exists()) || removedFileSet.contains(path))
            throw new FileNotFoundException(path);
        if (explodedFile.exists())
            return FileUtil.getFileAsInputStream(explodedFile);

        ZipEntry entry = new ZipEntry(path);
        ZipFile zipFile = getZipFile();
        InputStream in = null;
        try {
            in = zipFile.getInputStream(entry);
            if (in == null)
                throw new FileNotFoundException(path);
            String type = FileUtil.getContentType(in);
            if (log.isDebugEnabled() && type != null)
                log.debug("FileUtil.getContentType() returned " + type);
            return in;
        } catch (IOException ex) {
            // Don't need to close 'in' since zipFile.close() will do so
            IOUtils.closeQuietly(in);
            throw ex;
        }
    }

    public void close() {
        if (zFile != null) {
            try {
                zFile.close();
            } catch (IOException e) {
                // Ignore it
            }
            zFile = null;
        }
        if (tmpFile.exists())
            FileUtil.delete(tmpFile);
        propertyMap.clear();
        addedFileSet.clear();
        removedFileSet.clear();
        propsLoaded = false;
        dirty = !file.exists();
    }

    @Override
    protected void finalize() throws Throwable {
        close();
    }

    protected File getExplodedFile(String path) {
        return new File(tmpFile, path);
    }

    /**
     * Get all of the path names for this packed file.
     * 
     * @return All the path names. Changing this set does not affect the packed file. Changes to the file made after
     *         this method is called are not reflected in the path and do not cause a ConcurrentModificationException.
     *         Directories in the packed file are also included in the set.
     * @throws IOException
     *             Problem with the zip file.
     */
    public Set<String> getPaths() throws IOException {
        Set<String> paths = new HashSet<String>(addedFileSet);
        paths.add(CONTENT_FILE);
        paths.add(PROPERTY_FILE);
        if (file.exists()) {
            ZipFile zf = getZipFile();
            Enumeration<? extends ZipEntry> e = zf.entries();
            while (e.hasMoreElements()) {
                paths.add(e.nextElement().getName());
            }
        }
        paths.removeAll(removedFileSet);
        return paths;
    }

    /** @return Getter for file */
    public File getPackedFile() {
        return file;
    }

    /**
     * Return a URL for a path in this file.
     * 
     * @param path
     *            Get the url for this path
     * @return URL that can be used to access the file.
     * @throws IOException
     *             invalid zip file.
     */
    public URL getURL(String path) throws IOException {
        if (!hasFile(path))
            throw new FileNotFoundException("The path '" + path + "' is not in this packed file.");
        try {
            // Check for exploded first
            File explodedFile = getExplodedFile(path);
            if (explodedFile.exists())
                return explodedFile.toURI().toURL();

            // Otherwise it is in the zip file.
            if (!path.startsWith("/"))
                path = "/" + path;
            String url = "jar:" + file.toURI().toURL().toExternalForm() + "!" + path;
            return new URL(url);
        } catch (MalformedURLException e) {
            throw new IllegalArgumentException("Couldn't create a URL for path: '" + path + "'");
        }
    }

    /**
     * Remove a path from the packed file.
     * 
     * @param path
     *            Remove this path
     */
    public void removeFile(String path) {
        removedFileSet.add(path);
        addedFileSet.remove(path);
        File explodedFile = getExplodedFile(path);
        if (explodedFile.exists()) {
            explodedFile.delete();
        }
        dirty = true;
    }

    /**
     * Remove all files and directories from the zip file.
     * 
     * @throws IOException
     *             Problem reading the zip file.
     */
    public void removeAll() throws IOException {
        Set<String> paths = getPaths();
        for (String path : paths) {
            removeFile(path);
        } // endfor
    }

    /**
     * Create an output stream that will be written to the packed file. Caller is responsible for closing the stream.
     * 
     * @param path
     *            Path of the file being saved.
     * @return Stream that can be used to write the data.
     * @throws IOException
     *             Error opening the stream.
     */
    public OutputStream getOutputStream(String path) throws IOException {
        if (!tmpFile.exists()) {
            tmpFile.mkdirs();
        }
        File explodedFile = getExplodedFile(path);
        dirty = true;
        if (explodedFile.exists()) {
            return new FileOutputStream(explodedFile);
        } else {
            explodedFile.getParentFile().mkdirs();
        }
        addedFileSet.add(path);
        removedFileSet.remove(path);
        dirty = true;
        return new FileOutputStream(explodedFile);
    }
}