/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.gallery3d.data; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import com.android.gallery3d.app.GalleryApp; import com.android.gallery3d.common.LruCache; import com.android.gallery3d.common.Utils; import com.android.gallery3d.data.DownloadEntry.Columns; import com.android.gallery3d.util.Future; import com.android.gallery3d.util.FutureListener; import com.android.gallery3d.util.ThreadPool; import com.android.gallery3d.util.ThreadPool.CancelListener; import com.android.gallery3d.util.ThreadPool.Job; import com.android.gallery3d.util.ThreadPool.JobContext; import java.io.File; import java.net.URL; import java.util.HashMap; import java.util.HashSet; public class DownloadCache { private static final String TAG = "DownloadCache"; private static final int MAX_DELETE_COUNT = 16; private static final int LRU_CAPACITY = 4; private static final String TABLE_NAME = DownloadEntry.SCHEMA.getTableName(); private static final String QUERY_PROJECTION[] = {Columns.ID, Columns.DATA}; private static final String WHERE_HASH_AND_URL = String.format( "%s = ? AND %s = ?", Columns.HASH_CODE, Columns.CONTENT_URL); private static final int QUERY_INDEX_ID = 0; private static final int QUERY_INDEX_DATA = 1; private static final String FREESPACE_PROJECTION[] = { Columns.ID, Columns.DATA, Columns.CONTENT_URL, Columns.CONTENT_SIZE}; private static final String FREESPACE_ORDER_BY = String.format("%s ASC", Columns.LAST_ACCESS); private static final int FREESPACE_IDNEX_ID = 0; private static final int FREESPACE_IDNEX_DATA = 1; private static final int FREESPACE_INDEX_CONTENT_URL = 2; private static final int FREESPACE_INDEX_CONTENT_SIZE = 3; private static final String ID_WHERE = Columns.ID + " = ?"; private static final String SUM_PROJECTION[] = {String.format("sum(%s)", Columns.CONTENT_SIZE)}; private static final int SUM_INDEX_SUM = 0; private final LruCache mEntryMap = new LruCache(LRU_CAPACITY); private final HashMap mTaskMap = new HashMap(); private final File mRoot; private final GalleryApp mApplication; private final SQLiteDatabase mDatabase; private final long mCapacity; private long mTotalBytes = 0; private boolean mInitialized = false; public DownloadCache(GalleryApp application, File root, long capacity) { mRoot = Utils.checkNotNull(root); mApplication = Utils.checkNotNull(application); mCapacity = capacity; mDatabase = new DatabaseHelper(application.getAndroidContext()) .getWritableDatabase(); } private Entry findEntryInDatabase(String stringUrl) { long hash = Utils.crc64Long(stringUrl); String whereArgs[] = {String.valueOf(hash), stringUrl}; Cursor cursor = mDatabase.query(TABLE_NAME, QUERY_PROJECTION, WHERE_HASH_AND_URL, whereArgs, null, null, null); try { if (cursor.moveToNext()) { File file = new File(cursor.getString(QUERY_INDEX_DATA)); long id = cursor.getInt(QUERY_INDEX_ID); Entry entry = null; synchronized (mEntryMap) { entry = mEntryMap.get(stringUrl); if (entry == null) { entry = new Entry(id, file); mEntryMap.put(stringUrl, entry); } } return entry; } } finally { cursor.close(); } return null; } public Entry download(JobContext jc, URL url) { if (!mInitialized) initialize(); String stringUrl = url.toString(); // First find in the entry-pool synchronized (mEntryMap) { Entry entry = mEntryMap.get(stringUrl); if (entry != null) { updateLastAccess(entry.mId); return entry; } } // Then, find it in database TaskProxy proxy = new TaskProxy(); synchronized (mTaskMap) { Entry entry = findEntryInDatabase(stringUrl); if (entry != null) { updateLastAccess(entry.mId); return entry; } // Finally, we need to download the file .... // First check if we are downloading it now ... DownloadTask task = mTaskMap.get(stringUrl); if (task == null) { // if not, start the download task now task = new DownloadTask(stringUrl); mTaskMap.put(stringUrl, task); task.mFuture = mApplication.getThreadPool().submit(task, task); } task.addProxy(proxy); } return proxy.get(jc); } private void updateLastAccess(long id) { ContentValues values = new ContentValues(); values.put(Columns.LAST_ACCESS, System.currentTimeMillis()); mDatabase.update(TABLE_NAME, values, ID_WHERE, new String[] {String.valueOf(id)}); } private synchronized void freeSomeSpaceIfNeed(int maxDeleteFileCount) { if (mTotalBytes <= mCapacity) return; Cursor cursor = mDatabase.query(TABLE_NAME, FREESPACE_PROJECTION, null, null, null, null, FREESPACE_ORDER_BY); try { while (maxDeleteFileCount > 0 && mTotalBytes > mCapacity && cursor.moveToNext()) { long id = cursor.getLong(FREESPACE_IDNEX_ID); String url = cursor.getString(FREESPACE_INDEX_CONTENT_URL); long size = cursor.getLong(FREESPACE_INDEX_CONTENT_SIZE); String path = cursor.getString(FREESPACE_IDNEX_DATA); boolean containsKey; synchronized (mEntryMap) { containsKey = mEntryMap.containsKey(url); } if (!containsKey) { --maxDeleteFileCount; mTotalBytes -= size; new File(path).delete(); mDatabase.delete(TABLE_NAME, ID_WHERE, new String[]{String.valueOf(id)}); } else { // skip delete, since it is being used } } } finally { cursor.close(); } } private synchronized long insertEntry(String url, File file) { long size = file.length(); mTotalBytes += size; ContentValues values = new ContentValues(); String hashCode = String.valueOf(Utils.crc64Long(url)); values.put(Columns.DATA, file.getAbsolutePath()); values.put(Columns.HASH_CODE, hashCode); values.put(Columns.CONTENT_URL, url); values.put(Columns.CONTENT_SIZE, size); values.put(Columns.LAST_UPDATED, System.currentTimeMillis()); return mDatabase.insert(TABLE_NAME, "", values); } private synchronized void initialize() { if (mInitialized) return; mInitialized = true; if (!mRoot.isDirectory()) mRoot.mkdirs(); if (!mRoot.isDirectory()) { throw new RuntimeException("cannot create " + mRoot.getAbsolutePath()); } Cursor cursor = mDatabase.query( TABLE_NAME, SUM_PROJECTION, null, null, null, null, null); mTotalBytes = 0; try { if (cursor.moveToNext()) { mTotalBytes = cursor.getLong(SUM_INDEX_SUM); } } finally { cursor.close(); } if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT); } private final class DatabaseHelper extends SQLiteOpenHelper { public static final String DATABASE_NAME = "download.db"; public static final int DATABASE_VERSION = 2; public DatabaseHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } @Override public void onCreate(SQLiteDatabase db) { DownloadEntry.SCHEMA.createTables(db); // Delete old files for (File file : mRoot.listFiles()) { if (!file.delete()) { Log.w(TAG, "fail to remove: " + file.getAbsolutePath()); } } } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { //reset everything DownloadEntry.SCHEMA.dropTables(db); onCreate(db); } } public class Entry { public File cacheFile; protected long mId; Entry(long id, File cacheFile) { mId = id; this.cacheFile = Utils.checkNotNull(cacheFile); } } private class DownloadTask implements Job, FutureListener { private HashSet mProxySet = new HashSet(); private Future mFuture; private final String mUrl; public DownloadTask(String url) { mUrl = Utils.checkNotNull(url); } public void removeProxy(TaskProxy proxy) { synchronized (mTaskMap) { Utils.assertTrue(mProxySet.remove(proxy)); if (mProxySet.isEmpty()) { mFuture.cancel(); mTaskMap.remove(mUrl); } } } // should be used in synchronized block of mDatabase public void addProxy(TaskProxy proxy) { proxy.mTask = this; mProxySet.add(proxy); } @Override public void onFutureDone(Future future) { File file = future.get(); long id = 0; if (file != null) { // insert to database id = insertEntry(mUrl, file); } if (future.isCancelled()) { Utils.assertTrue(mProxySet.isEmpty()); return; } synchronized (mTaskMap) { Entry entry = null; synchronized (mEntryMap) { if (file != null) { entry = new Entry(id, file); Utils.assertTrue(mEntryMap.put(mUrl, entry) == null); } } for (TaskProxy proxy : mProxySet) { proxy.setResult(entry); } mTaskMap.remove(mUrl); freeSomeSpaceIfNeed(MAX_DELETE_COUNT); } } @Override public File run(JobContext jc) { // TODO: utilize etag jc.setMode(ThreadPool.MODE_NETWORK); File tempFile = null; try { URL url = new URL(mUrl); tempFile = File.createTempFile("cache", ".tmp", mRoot); // download from url to tempFile jc.setMode(ThreadPool.MODE_NETWORK); boolean downloaded = DownloadUtils.requestDownload(jc, url, tempFile); jc.setMode(ThreadPool.MODE_NONE); if (downloaded) return tempFile; } catch (Exception e) { Log.e(TAG, String.format("fail to download %s", mUrl), e); } finally { jc.setMode(ThreadPool.MODE_NONE); } if (tempFile != null) tempFile.delete(); return null; } } public static class TaskProxy { private DownloadTask mTask; private boolean mIsCancelled = false; private Entry mEntry; synchronized void setResult(Entry entry) { if (mIsCancelled) return; mEntry = entry; notifyAll(); } public synchronized Entry get(JobContext jc) { jc.setCancelListener(new CancelListener() { @Override public void onCancel() { mTask.removeProxy(TaskProxy.this); synchronized (TaskProxy.this) { mIsCancelled = true; TaskProxy.this.notifyAll(); } } }); while (!mIsCancelled && mEntry == null) { try { wait(); } catch (InterruptedException e) { Log.w(TAG, "ignore interrupt", e); } } jc.setCancelListener(null); return mEntry; } } }