/*
* Copyright (C) 2008 Josh Guilfoyle <jasta@devtcg.org>
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2, or (at your option) any
* later version.
*
* This program 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.
*/
package org.devtcg.five.service;
import java.io.File;
import java.io.IOException;
import org.devtcg.five.provider.Five;
import org.devtcg.five.util.FileUtils;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Environment;
import android.os.StatFs;
import android.util.Log;
/**
* Mechanism for managing cached content.
*
* TODO: Implement this using configurable CachePolicy's. For now it is
* hardcoded with a policy which attempts to leave 100MB free on the storage
* card for other applications.
*/
public class CacheManager
{
private static final String TAG = "CacheManager";
private static CacheManager INSTANCE;
/* XXX: The default policy (which is not configurable, sadly) is to
* attempt to leave 100MB free. This should be made far more robust
* and flexible in the future. */
private static final int POLICY_LEAVE_FREE = 100 * 1024 * 1024;
private CacheManager() {}
public synchronized static CacheManager getInstance()
{
if (INSTANCE == null)
INSTANCE = new CacheManager();
return INSTANCE;
}
private String getContentWhereClause(long sourceId, long contentId)
{
return Five.Music.Songs.SOURCE_ID + " = " + sourceId + " AND " +
Five.Music.Songs._SYNC_ID + " = " + contentId;
}
private Cursor getContentCursor(Context context, long sourceId, long contentId)
{
String fields[] =
new String[] { Five.Music.Songs._ID, Five.Music.Songs.SIZE,
Five.Music.Songs.CACHED_PATH, Five.Music.Songs.MIME_TYPE };
return context.getContentResolver()
.query(Five.Music.Songs.CONTENT_URI, fields,
getContentWhereClause(sourceId, contentId), null, null);
}
private int updateContentRow(Context context, long sourceId, long contentId,
ContentValues values)
{
return context.getContentResolver().update(Five.Music.Songs.CONTENT_URI, values,
getContentWhereClause(sourceId, contentId), null);
}
private boolean deleteSufficientSpace(Context context, File sdcard, long size)
{
ContentResolver cr = null;
Cursor c = null;
OUTER:
while (true)
{
StatFs fs = new StatFs(sdcard.getAbsolutePath());
long freeBytes = (long)fs.getAvailableBlocks() * fs.getBlockSize();
long necessary = POLICY_LEAVE_FREE - (freeBytes + size);
if (necessary <= 0)
return true;
Log.i(TAG, "Hunting for cache entries to delete (need " + necessary + " more bytes)...");
/* Initialize lazy so that for the common case of there
* being enough space we don't have to perform a query. */
if (cr == null)
{
cr = context.getContentResolver();
c = cr.query(Five.Music.Songs.CONTENT_URI,
new String[] { Five.Music.Songs._ID,
Five.Music.Songs.CACHED_PATH, Five.Music.Songs.SIZE },
Five.Music.Songs.CACHED_PATH + " IS NOT NULL", null,
Five.Music.Songs.CACHED_TIMESTAMP + " ASC");
}
/* Inner loop here to avoid calling StatFs for each cached
* entry delete. Only call it again to confirm what we
* gathered from the database. */
while (necessary >= 0)
{
if (c.moveToNext() == false)
break OUTER;
File f = new File(c.getString(1));
if (f.exists() == true)
{
/* The file's size might differ from the databases as we
* may have an uncommitted, partial cache hit. */
long cachedSize = f.length();
if (f.delete() == true)
necessary -= cachedSize;
}
/* Eliminate this entry from the cache. */
Uri contentUri =
ContentUris.withAppendedId(Five.Music.Songs.CONTENT_URI,
c.getLong(0));
ContentValues cv = new ContentValues();
cv.putNull(Five.Music.Songs.CACHED_TIMESTAMP);
cv.putNull(Five.Music.Songs.CACHED_PATH);
cr.update(contentUri, cv, null, null);
}
}
if (c != null)
c.close();
return false;
}
private String getExtensionFromMimeType(String mime)
{
if (mime.equals("audio/mpeg") == true)
return "mp3";
else if (mime.equals("application/ogg") == true)
return "ogg";
else if (mime.equals("audio/mp4a-latm") == true)
return "m4a";
else if (mime.equals("audio/mp4") == true)
return "mp4";
throw new IllegalArgumentException("Unknown mime type " + mime);
}
/**
* Attempt to carve out sufficient storage from the storage card.
*
* @param size
* Required amount of available space.
*
* @return
* Filename for storage.
*/
private String makeStorage(Context context, long sourceId, long contentId,
String mime, long size)
throws CacheAllocationException
{
String state = Environment.getExternalStorageState();
if (state.equals(Environment.MEDIA_MOUNTED) == false)
throw new NoStorageCardException();
File sdcard = Environment.getExternalStorageDirectory();
if (sdcard.exists() == false)
throw new NoStorageCardException();
if (deleteSufficientSpace(context, sdcard, size) == false)
throw new OutOfSpaceException();
String basePath = sdcard.getAbsolutePath() + "/five/cache/" + sourceId;
File basePathFile = new File(basePath);
if (basePathFile.exists() == false)
{
if (basePathFile.mkdirs() == false)
throw new CacheAllocationException("Could not create cache directory: " + basePath);
}
return basePath + '/' + contentId + '.' +
getExtensionFromMimeType(mime);
}
/**
* Request storage space for a new content item. If this entry is
* already committed to cache then the cached entry will be
* truncated. Caller is responsible for calling either
* {@link #commitStorage} or {@link #releaseStorage} when finished.
*
* @throws IllegalStateException
* Illegal state exception is thrown if another active CacheEntry
* object has been provided but not released.
*
* @return
* The path to the allocated storage.
*/
public String requestStorage(Context context, long sourceId, long contentId)
throws CacheAllocationException
{
Cursor c = getContentCursor(context, sourceId, contentId);
try {
if (c.moveToFirst() == false)
throw new IllegalArgumentException("Invalid content");
long size = c.getLong(c.getColumnIndexOrThrow(Five.Music.Songs.SIZE));
String mime = c.getString(c.getColumnIndexOrThrow(Five.Music.Songs.MIME_TYPE));
String path = makeStorage(context, sourceId, contentId, mime, size);
ContentValues cv = new ContentValues();
cv.put(Five.Music.Songs.CACHED_TIMESTAMP,
System.currentTimeMillis());
cv.put(Five.Music.Songs.CACHED_PATH, path);
updateContentRow(context, sourceId, contentId, cv);
return path;
} finally {
c.close();
}
}
/**
* Commit cached content to disk. This indicates that the file is fully
* downloaded and that the cached entry should be tidied.
*/
public void commitStorage(long sourceId, long contentId)
{
/* XXX. */
}
/**
* Inform the cache manager that an entry can be purged. This may not
* result in an immediate release of resources depending on the current
* storage policy. Likewise, cached entries may be purged
* automatically by the cache manager without having been explicitly
* released.
*
* XXX: This method is not currently used.
*/
public void releaseStorage(long sourceId, long contentId)
{
throw new RuntimeException("Not implemented");
}
/**
* Delete all meta data. This is a special case during first-time set up to
* ensure that any previous installations data is removed before proceeding.
*/
public void wipeAll() throws IOException
{
File fiveStorage = new File(Environment.getExternalStorageDirectory(), "five");
if (fiveStorage.exists())
FileUtils.deleteDirectory(fiveStorage);
}
/**
* Delete artist meta data.
*/
public void deleteArtist(long artistId)
{
}
public static class CacheAllocationException extends Exception
{
public CacheAllocationException() { super(); }
public CacheAllocationException(String msg) { super(msg); }
}
public static class OutOfSpaceException extends CacheAllocationException
{
public OutOfSpaceException() {
super("Available storage card space has been exhausted");
}
}
public static class NoStorageCardException extends CacheAllocationException
{
public NoStorageCardException() {
super("No storage card mounted");
}
}
}