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
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
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
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
302 final Object fetchInfo;
303 try {
304 fetchInfo = downloadQueue.keySet().iterator().next();
305 } catch (final NoSuchElementException ex) {
306
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
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
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
465 final File file = theCache.get(nameAndExt[0]);
466 if (file != null)
467 return file;
468 fetchFileAsync(fetchInfo);
469 return null;
470 }
471 }