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
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
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
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
303 final Object fetchInfo;
304 try {
305 fetchInfo = downloadQueue.keySet().iterator().next();
306 } catch (final NoSuchElementException ex) {
307
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
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
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
471 final File file = theCache.get(nameAndExt[0]);
472 if (file != null)
473 return file;
474 fetchFileAsync(fetchInfo);
475 return null;
476 }
477 }