/* * Aphelion * Copyright (c) 2013 Joris van der Wel * * This file is part of Aphelion * * Aphelion is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, version 3 of the License. * * Aphelion 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 Affero General Public License * along with Aphelion. If not, see <http://www.gnu.org/licenses/>. * * In addition, the following supplemental terms apply, based on section 7 of * the GNU Affero General Public License (version 3): * a) Preservation of all legal notices and author attributions * b) Prohibition of misrepresentation of the origin of this material, and * modified versions are required to be marked in reasonable ways as * different from the original version (for example by appending a copyright notice). * * Linking this library statically or dynamically with other modules is making a * combined work based on this library. Thus, the terms and conditions of the * GNU Affero General Public License cover the whole combination. * * As a special exception, the copyright holders of this library give you * permission to link this library with independent modules to produce an * executable, regardless of the license terms of these independent modules, * and to copy and distribute the resulting executable under terms of your * choice, provided that you also meet, for each linked independent module, * the terms and conditions of the license of that module. An independent * module is a module which is not derived from or based on this library. */ package aphelion.shared.resource; import aphelion.client.resource.AsyncTextureLoader; import aphelion.shared.event.LoopEvent; import aphelion.shared.event.Workable; import aphelion.shared.event.WorkerTask; import aphelion.shared.event.promise.AbstractPromise; import aphelion.shared.event.promise.PromiseException; import aphelion.shared.swissarmyknife.SwissArmyKnife; import aphelion.shared.swissarmyknife.ThreadSafe; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.lang.ref.SoftReference; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map.Entry; import java.util.Properties; import java.util.logging.Level; import java.util.logging.Logger; import java.util.zip.ZipEntry; import java.util.zip.ZipException; import java.util.zip.ZipFile; /** Represents a local key value database for aphelion resources. * All resources have a key which is used throughout the aphelion graphics code. * The key might be used statically in the source code or concatenated using dots, * for example "ship." + myShip.key + ".shipRoll" * * Resources are read from zip files on the local file system. Each zip file should * contain a text file called "resources.manifest", this is a java property file (java.util.Properties). * This database reads all entries from the manifest that begin with "resource.". * For example to define the resource "ship.warbird.shipRoll" the following entry could be used: * "ship.warbird.shipRoll = ships/warbird.png" * * A (cached) byte array may be returned using a callback, or an InputStream may be returned. * * @author Joris */ // At the moment the header of the zip file is reread for every resource file that is read. // This could be optimized if needed public class ResourceDB implements LoopEvent { private static final Logger log = Logger.getLogger("aphelion.resource"); private AsyncTextureLoader textureLoader; private final LinkedHashMap<String, FileEntry> entries = new LinkedHashMap<>(); private final Workable workable; private final HashMap<String, SoftReference<byte[]>> byteCache = new HashMap<>(); private final LinkedList<LoadSynchronizer> loadSynchronizers = new LinkedList<>(); private int cleanupCounter = 0; public ResourceDB(Workable workable) { this.workable = workable; } @Override public void loop(long systemNanoTime, long sourceNanoTime) { if (textureLoader != null) { textureLoader.loop(systemNanoTime, sourceNanoTime); } } public AsyncTextureLoader getTextureLoader() { if (textureLoader == null) { textureLoader = new AsyncTextureLoader(this, workable); } return textureLoader; } @ThreadSafe public boolean resourceExists(String key) { synchronized(this) { return entries.containsKey(key); } } @ThreadSafe public List<String> getKeysByPrefix(String prefix) { LinkedList<String> ret = new LinkedList<>(); synchronized(this) { for (FileEntry entry : entries.values()) { if (entry.key.startsWith(prefix)) { ret.add(entry.key); } } } return ret; } /** Returns the resource as a byte array. * This call may block for a while. * @param key The resource key * @return A byte array that should be considered immutable. * Or null if the resource was not defined. */ @ThreadSafe public byte[] getBytesSync(String key) { InputStream is = getInputStreamSync(key); if (is == null) { return null; } byte[] bytes = null; synchronized(this) { SoftReference<byte[]> ref = byteCache.get(key); if (ref != null) { bytes = ref.get(); } } if (bytes == null) { // not found in cache LoadSynchronizer loadSync = null; // synchronize the loading so that the same file is not read from disk multiple times // The second thread that attempts to load a file will block until the first thread is done /// TODO test synchronized(this) { for (LoadSynchronizer loadSyncOther : loadSynchronizers) { if (loadSyncOther.resourceKey.equals(key)) { loadSync = loadSyncOther; break; } } if (loadSync == null) { loadSync = new LoadSynchronizer(key); loadSynchronizers.add(loadSync); } } synchronized(loadSync) { // reattempt cache synchronized(this) { SoftReference<byte[]> ref = byteCache.get(key); if (ref != null) { bytes = ref.get(); } } if (bytes == null) { // this thread is the first one to acquire the load lock try { bytes = SwissArmyKnife.inputStreamToBytes(is); } catch (IOException ex) { log.log(Level.SEVERE, "Error while reading resource", ex); return null; } synchronized(this) { byteCache.put(key, new SoftReference<>(bytes)); } } } synchronized(this) { loadSynchronizers.remove(loadSync); } } // TODO: use the cache class from google guave instead? synchronized(this) { if (++cleanupCounter > 10) { cleanupCounter = 0; Iterator<SoftReference<byte[]>> it = byteCache.values().iterator(); while (it.hasNext()) { if (it.next().get() == null) { it.remove(); } } } } return bytes; } /** Returns the resource as a byte array asynchronously using a callback. * @param key The resource key * @return The promise that will be fired on the main thread * when the resource has been read. The byte array given should be * considered immutable. If the byte array is null, the resource does * not exist or an unexpected error occurred. * @throws IllegalStateException When a workable object was not given * during construction */ @ThreadSafe public AbstractPromise getBytes(String key) { if (workable == null) { throw new IllegalStateException(); } return workable.addWorkerTask(new GetBytesTask(), key); } /** Returns the location of a resource on the filesystem. * @param key The resource key * @return null if no such resource was defined */ @ThreadSafe public FileEntry getFileEntry(String key) { synchronized(this) { return entries.get(key); } } /** Returns the resource as an InputStream without doing any reading. * This avoids the resource cache (unlike getWrappedInputStream) * This might block execution for a little as java queries the filesystem. * @param key The resource key * @return */ @ThreadSafe public InputStream getInputStreamSync(String key) { FileEntry file = getFileEntry(key); if (file == null) { return null; } try { if (file.zipEntry == null) { return new FileInputStream(file.file); } else { ZipFile zip = new ZipFile(file.file); return zip.getInputStream(zip.getEntry(file.zipEntry)); } } catch (SecurityException | IOException ex) { log.log(Level.SEVERE, "Unable to read resource", ex); return null; } } /** Read the file as byte array and then wrap that byte array in an InputStream. * This lets you read the file in a worker thread while still providing an * InputStream to API's that require it. * This also takes advantage of our resource cache. * @param key The resource key * @return */ public AbstractPromise getWrappedInputStream(String key) { if (workable == null) { throw new IllegalStateException(); } return workable.addWorkerTask(new WrappedInputStreamTask(), key); } /** Add a resource as a file that is not part of a zip file. * This method immediately checks if the file is readable to help * avoid errors from occurring later on. * @param key The resource key * @param path The path to the file that should represent the resource. * @throws FileNotFoundException * @throws SecurityException */ @ThreadSafe public void addResource(String key, File path) throws FileNotFoundException, SecurityException { if (!path.exists()) { throw new FileNotFoundException(); } SecurityManager security = System.getSecurityManager(); if (security != null) { security.checkRead(path.getPath()); } if (!path.canRead()) { throw new FileNotFoundException("Unable to read this file"); } synchronized(this) { entries.put(key, new FileEntry(key, path, null)); } } /** Add resources using a zip file. * The zip file should contain a resource manifest file denoting what resources it should add. * The file "manifest" is read as a java property file. A key beginning with * "resource." is used as the resource key (excluding the "resource." prefix). * The value should be the path within the zip file to the resource file. * @param file The zip file, a .zip file extension is not required. * @throws ZipException * @throws IOException */ @ThreadSafe public void addZip(File file) throws ZipException, IOException { ZipFile zip = new ZipFile(file); ZipEntry zipManifest = zip.getEntry("resources.manifest"); Properties manifest = new Properties(); manifest.load(zip.getInputStream(zipManifest)); synchronized(this) { if (manifest.entrySet() != null) { Iterator<Entry<Object, Object>> it = manifest.entrySet().iterator(); while (it.hasNext()) { Entry<Object, Object> entry = it.next(); String key = (String) entry.getKey(); String value = (String) entry.getValue(); ZipEntry zipEntry = zip.getEntry(value); if (zipEntry == null) { log.log(Level.SEVERE, "Entry in zip manifest does not exist. Zip: {0}. Entry: {1}", new Object[] {file.getPath(), value}); } else { // (replace old mapping) entries.put(key, new FileEntry(key, file, value)); } } } } } private class GetBytesTask extends WorkerTask<String, byte[]> { @Override public byte[] work(String argument) throws PromiseException { return getBytesSync(argument); } } private class WrappedInputStreamTask extends WorkerTask<String, InputStream> { @Override public InputStream work(String argument) throws PromiseException { byte[] bytes = getBytesSync(argument); if (bytes == null) { return null; } return new ByteArrayInputStream(bytes); } } public static class FileEntry { final String key; /** The path to the resource file (if this.zipEntry is null), or the path to the zip file. */ final public File file; /** The path of the resource within the zip file (this.file) */ final public String zipEntry; FileEntry(String key, File file, String zipEntry) { this.key = key; this.file = file; this.zipEntry = zipEntry; assert file != null; } } private static class LoadSynchronizer { final String resourceKey; LoadSynchronizer(String resourceKey) { this.resourceKey = resourceKey; } } }