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