View Javadoc

1   /***
2    *     Ambient - A music player for the Android platform
3    Copyright (C) 2007 Martin Vysny
4    
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU General Public License as published by
7    the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9    
10   This program is distributed in the hope that it will be useful,
11   but WITHOUT ANY WARRANTY; without even the implied warranty of
12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   GNU General Public License for more details.
14  
15   You should have received a copy of the GNU General Public License
16   along with this program.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  package sk.baka.ambient.commons;
19  
20  import java.io.File;
21  import java.io.FileNotFoundException;
22  import java.io.FileOutputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.net.URL;
26  import java.util.Arrays;
27  import java.util.Collections;
28  import java.util.HashSet;
29  import java.util.Map;
30  import java.util.NoSuchElementException;
31  import java.util.Set;
32  import java.util.concurrent.ConcurrentHashMap;
33  import java.util.concurrent.ConcurrentMap;
34  
35  import sk.baka.ambient.AmbientApplication;
36  import sk.baka.ambient.NotifyingInputStream;
37  import sk.baka.ambient.R;
38  import android.content.Context;
39  
40  /***
41   * An abstract file cache, stores files into android application private
42   * directory. Thread-safe. Essentially a singleton, there must be at most one
43   * instance.
44   * 
45   * @author Martin Vysny
46   */
47  public class AbstractFileStorage {
48  	/***
49  	 * Set of names for which a file is available.
50  	 */
51  	private final Map<String, File> cache = new ConcurrentHashMap<String, File>();
52  
53  	/***
54  	 * A read-only view of the cache.
55  	 */
56  	protected final Map<String, File> theCache = Collections
57  			.unmodifiableMap(cache);
58  
59  	/***
60  	 * Lengths of all files.
61  	 */
62  	private volatile long cacheSize = 0;
63  
64  	/***
65  	 * All valid file extensions for this cache.
66  	 */
67  	private final Set<String> ext;
68  
69  	/***
70  	 * describes content which this storage holds. Should form a correct
71  	 * sentence with <code>"Downloading " + contentDesc + " failed"</code>.
72  	 */
73  	private final String contentDesc;
74  
75  	/***
76  	 * Creates new cache instance.
77  	 * 
78  	 * @param ext
79  	 *            a list of ".whatever" strings - valid extensions.
80  	 * @param contentDesc
81  	 *            describes content which this storage holds. Should form a
82  	 *            correct sentence with
83  	 *            <code>"Downloading " + contentDesc + " failed"</code>.
84  	 */
85  	public AbstractFileStorage(final String contentDesc, final String... ext) {
86  		super();
87  		this.contentDesc = contentDesc;
88  		this.ext = new HashSet<String>(Arrays.asList(ext));
89  		final String[] files = AmbientApplication.getInstance().fileList();
90  		if (files != null) {
91  			for (final String file : files) {
92  				if (!supportsExtension(getExtension(file)))
93  					continue;
94  				final File f = AmbientApplication.getInstance()
95  						.getFileStreamPath(file);
96  				cache.put(getNameFromFile(file), f);
97  				cacheSize += f.length();
98  			}
99  		}
100 	}
101 
102 	/***
103 	 * Checks if this storage supports given extension.
104 	 * 
105 	 * @param ext
106 	 *            the ".extension" format
107 	 * @return <code>true</code> if this storage supports such extensions,
108 	 *         <code>false</code> otherwise.
109 	 */
110 	protected final boolean supportsExtension(final String ext) {
111 		return this.ext.contains(ext);
112 	}
113 
114 	private File getFileFromName(String name, String ext) {
115 		final File result = AmbientApplication.getInstance().getFileStreamPath(
116 				getFileNameFromName(name, ext));
117 		return result;
118 	}
119 
120 	private String getFileNameFromName(String name, String ext) {
121 		// escape the name
122 		String n = name.replace("_", "__");
123 		n = n.replace("/", "_D");
124 		n = n.replace("//", "_d");
125 		return n + ext;
126 	}
127 
128 	private String getExtension(String file) {
129 		int extIndex = file.lastIndexOf('.');
130 		if (extIndex < 0)
131 			extIndex = file.length();
132 		final String fileext = file.substring(extIndex);
133 		return fileext;
134 	}
135 
136 	private String getNameFromFile(String file) {
137 		// un-escape the name
138 		String f = file.replace("_D", "/");
139 		f = f.replace("_d", "//");
140 		f = f.replace("__", "_");
141 		int extIndex = f.lastIndexOf('.');
142 		if (extIndex < 0)
143 			extIndex = f.length();
144 		final String name = f.substring(0, extIndex);
145 		return name;
146 	}
147 
148 	/***
149 	 * Maximum storage size, in bytes.
150 	 */
151 	protected long maxStorageSize = 256 * 1024;
152 
153 	/***
154 	 * Sets maximum cache size, removing files if required.
155 	 * 
156 	 * @param maxCacheSize
157 	 *            Maximum cache size, in bytes.
158 	 */
159 	public final void setMaxStorageSize(final long maxCacheSize) {
160 		this.maxStorageSize = maxCacheSize;
161 		cleanup();
162 	}
163 
164 	/***
165 	 * Returns <code>true</code> if this storage is full.
166 	 * 
167 	 * @return <code>true</code> if the size of all files in this storage
168 	 *         exceeds {@link #setMaxStorageSize(long) maximum storage size}.
169 	 */
170 	public final synchronized boolean isFull() {
171 		return cacheSize >= maxStorageSize;
172 	}
173 
174 	/***
175 	 * Cleans up old files. Superclass should implement a strategy to pick and
176 	 * delete obsolete files. Default implementation does nothing. Called in
177 	 * {@link #setMaxStorageSize(long)} method.
178 	 */
179 	protected void cleanup() {
180 		// let overriding classes implement this method
181 	}
182 
183 	/***
184 	 * Registers a file to the cache. The file must exist. The file name must be
185 	 * passable to the {@link Context#getFileStreamPath(String)} method.
186 	 * 
187 	 * @param name
188 	 *            the file name, without the extension
189 	 * @param ext
190 	 *            the ".ext" string.
191 	 * @return the name the file is registered under.
192 	 * @throws FileNotFoundException
193 	 *             if the file does not exist.
194 	 */
195 	protected final FileOutputStream createFile(final String name,
196 			final String ext) throws FileNotFoundException {
197 		checkExtension(ext);
198 		return AmbientApplication.getInstance().openFileOutput(
199 				getFileNameFromName(name, ext), Context.MODE_PRIVATE);
200 	}
201 
202 	private void checkExtension(final String ext) {
203 		if (!supportsExtension(ext)) {
204 			throw new IllegalArgumentException("Unsupported extension: " + ext);
205 		}
206 	}
207 
208 	/***
209 	 * Registers a file to the cache. The file must have been created using
210 	 * {@link #createFile(String, String)}.
211 	 * 
212 	 * @param name
213 	 *            the file name, without the extension
214 	 * @param ext
215 	 *            the ".ext" string.
216 	 */
217 	protected final void registerFile(final String name, final String ext) {
218 		checkExtension(ext);
219 		final File file = getFileFromName(name, ext);
220 		cacheSize += file.length();
221 		cache.put(name, file);
222 	}
223 
224 	/***
225 	 * Removes a file from the cache. The file must exist. The file name must be
226 	 * passable to the {@link Context#getFileStreamPath(String)} method.
227 	 * 
228 	 * @param file
229 	 *            the file
230 	 */
231 	protected final void removeFile(final File file) {
232 		if (file.exists()) {
233 			cacheSize -= file.length();
234 			AmbientApplication.getInstance().deleteFile(file.getName());
235 		}
236 		cache.remove(getNameFromFile(file.getName()));
237 	}
238 
239 	/***
240 	 * Retrieves cached file.
241 	 * 
242 	 * @param name
243 	 *            the name
244 	 * @return File
245 	 */
246 	protected final File getCacheFile(final String name) {
247 		final File result = getCacheFileNull(name);
248 		if (result == null)
249 			throw new IllegalArgumentException("No such file in cache: " + name);
250 		return result;
251 	}
252 
253 	/***
254 	 * Retrieves cached file. May return <code>null</code> if no such file
255 	 * exists.
256 	 * 
257 	 * @param name
258 	 *            the name
259 	 * @return File
260 	 */
261 	protected final File getCacheFileNull(final String name) {
262 		return cache.get(name);
263 	}
264 
265 	/***
266 	 * Purges the cache - removes all cached files.
267 	 */
268 	public final synchronized void purge() {
269 		for (final File file : cache.values()) {
270 			cacheSize -= file.length();
271 			AmbientApplication.getInstance().deleteFile(file.getName());
272 		}
273 		cache.clear();
274 	}
275 
276 	/***
277 	 * Returns a list of names of objects cached in this storage.
278 	 * 
279 	 * @return the storage.
280 	 */
281 	public Set<String> getNames() {
282 		return theCache.keySet();
283 	}
284 
285 	/***
286 	 * Contains set of objects for which the file is currently being downloaded.
287 	 */
288 	private final ConcurrentMap<Object, Object> downloadQueue = new ConcurrentHashMap<Object, Object>();
289 
290 	/***
291 	 * Each instance of the file storage should have its own download queue.
292 	 */
293 	private final Object taskType = new Object();
294 
295 	private final Runnable downloader = new Runnable() {
296 		public void run() {
297 			while (true) {
298 				if (Thread.currentThread().isInterrupted()) {
299 					return;
300 				}
301 				// grab random item
302 				final Object fetchInfo;
303 				try {
304 					fetchInfo = downloadQueue.keySet().iterator().next();
305 				} catch (final NoSuchElementException ex) {
306 					// ugly, but thread-safe
307 					return;
308 				}
309 				downloadQueue.remove(fetchInfo);
310 				if (isProceedWithDownload(fetchInfo)) {
311 					try {
312 						fetchFileSync(fetchInfo);
313 					} catch (Exception ex) {
314 						throw new RuntimeException(ex.getMessage(), ex);
315 					}
316 				}
317 			}
318 		}
319 	};
320 
321 	/***
322 	 * Attempts to asynchronously download a file. Does nothing if the file is
323 	 * already scheduled to be downloaded.
324 	 * 
325 	 * @param fetchInfo
326 	 *            contains information on how to fetch the file.
327 	 *            {@link #toURL(Object)} is called asynchronously to convert the
328 	 *            fetch info object to an URL. This object must comply to the
329 	 *            contract imposed on a {@link Map} key.
330 	 */
331 	protected final void fetchFileAsync(final Object fetchInfo) {
332 		if (closing)
333 			return;
334 		if (downloadQueue.putIfAbsent(fetchInfo, MiscUtils.NULL) != null)
335 			return;
336 		final boolean isScheduled = AmbientApplication.getInstance()
337 				.getBackgroundTasks().schedule(
338 						downloader,
339 						taskType,
340 						true,
341 						AmbientApplication.getInstance().getString(
342 								R.string.downloading)
343 								+ " " + contentDesc);
344 		if (!isScheduled) {
345 			downloadQueue.clear();
346 		}
347 	}
348 
349 	private volatile boolean closing = false;
350 
351 	/***
352 	 * Converts given fetch info to an URL object. Default implementation throws
353 	 * {@link UnsupportedOperationException}. Must be thread-safe as it is not
354 	 * invoked from a Handler thread.
355 	 * 
356 	 * @param fetchInfo
357 	 *            the fetch info object, never <code>null</code>.
358 	 * @return URL which is used to download the file contents. If
359 	 *         <code>null</code> then this download is aborted.
360 	 * @throws IOException
361 	 *             if i/o error occurs
362 	 */
363 	protected URL toURL(final Object fetchInfo) throws IOException {
364 		throw new UnsupportedOperationException("unimplemented");
365 	}
366 
367 	/***
368 	 * Invoked before the download process starts. Must be thread-safe as it is
369 	 * not invoked from a Handler thread.
370 	 * 
371 	 * @param fetchInfo
372 	 *            the fetch info object, never <code>null</code>.
373 	 * @return if <code>false</code> then this download is aborted.
374 	 */
375 	protected boolean isProceedWithDownload(final Object fetchInfo) {
376 		return true;
377 	}
378 
379 	/***
380 	 * Closes the cache object, stopping any pending download operations.
381 	 */
382 	public void close() {
383 		closing = true;
384 		downloadQueue.clear();
385 	}
386 
387 	private void fetchFileSync(final Object fetchInfo) throws IOException {
388 		if (Thread.currentThread().isInterrupted())
389 			return;
390 		final URL url = toURL(fetchInfo);
391 		if (url == null) {
392 			// no url, bail out
393 			onFileDownloaded(null, fetchInfo, false);
394 			return;
395 		}
396 		if (Thread.currentThread().isInterrupted()) {
397 			return;
398 		}
399 		final String[] filename = getFilenameAndExt(url, fetchInfo);
400 		if (filename != null) {
401 			final InputStream in = NotifyingInputStream.fromURL(url, 3,
402 					AmbientApplication.getInstance().getString(
403 							R.string.downloading)
404 							+ " " + contentDesc);
405 			IOUtils.copy(in, createFile(filename[0], filename[1]), 1024);
406 			registerFile(filename[0], filename[1]);
407 			onFileDownloaded(url, fetchInfo, true);
408 		}
409 	}
410 
411 	/***
412 	 * The file was downloaded and is already registered in the storage. Default
413 	 * implementation does nothing. Must be thread-safe as it is not invoked
414 	 * from a Handler thread.
415 	 * 
416 	 * @param url
417 	 *            the source URL
418 	 * @param fetchInfo
419 	 *            the fetch info object
420 	 * @param success
421 	 *            if <code>true</code> then the file is available in the cache.
422 	 *            If <code>false</code> then the download process was aborted (
423 	 *            {@link #toURL(Object)} returned <code>null</code>).
424 	 */
425 	protected void onFileDownloaded(final URL url, final Object fetchInfo,
426 			final boolean success) {
427 		// do nothing.
428 	}
429 
430 	/***
431 	 * Retrieves target file name. Default implementation throws
432 	 * {@link UnsupportedOperationException}. Must be thread-safe as it is not
433 	 * invoked from a Handler thread.
434 	 * 
435 	 * @param url
436 	 *            the source URL. May be <code>null</code> - in this case the
437 	 *            extension is not needed and returned extension may be
438 	 *            <code>null</code> or the result may contain only the name.
439 	 * @param fetchInfo
440 	 *            the fetch info object
441 	 * @return array containing two items - filename and extension. The
442 	 *         extension must start with a dot. If <code>null</code> is returned
443 	 *         then the object is not downloadable nor storable into the
444 	 *         storage.
445 	 */
446 	protected String[] getFilenameAndExt(final URL url, final Object fetchInfo) {
447 		throw new UnsupportedOperationException("unimplemented");
448 	}
449 
450 	/***
451 	 * Fetches a file. Attempts to fetch it from the internet if the file is not
452 	 * found and we are online - in this case a background thread is run and the
453 	 * method returns <code>null</code>.
454 	 * 
455 	 * @param fetchInfo
456 	 *            the fetch info object
457 	 * @return file or <code>null</code> if we are unable to provide any file
458 	 *         now.
459 	 */
460 	protected File getFile(final Object fetchInfo) {
461 		final String[] nameAndExt = getFilenameAndExt(null, fetchInfo);
462 		if (nameAndExt == null)
463 			return null;
464 		// first, try the cache
465 		final File file = theCache.get(nameAndExt[0]);
466 		if (file != null)
467 			return file;
468 		fetchFileAsync(fetchInfo);
469 		return null;
470 	}
471 }