1 /*
2  * Copyright (C) 2011 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.gallery3d.common;
18 
19 import android.content.ContentValues;
20 import android.content.Context;
21 import android.database.Cursor;
22 import android.database.sqlite.SQLiteDatabase;
23 import android.database.sqlite.SQLiteOpenHelper;
24 import android.util.Log;
25 
26 import com.android.gallery3d.common.Entry.Table;
27 
28 import java.io.Closeable;
29 import java.io.File;
30 import java.io.IOException;
31 
32 public class FileCache implements Closeable {
33     private static final int LRU_CAPACITY = 4;
34     private static final int MAX_DELETE_COUNT = 16;
35 
36     private static final String TAG = "FileCache";
37     private static final String TABLE_NAME = FileEntry.SCHEMA.getTableName();
38     private static final String FILE_PREFIX = "download";
39     private static final String FILE_POSTFIX = ".tmp";
40 
41     private static final String QUERY_WHERE =
42             FileEntry.Columns.HASH_CODE + "=? AND " + FileEntry.Columns.CONTENT_URL + "=?";
43     private static final String ID_WHERE = FileEntry.Columns.ID + "=?";
44     private static final String[] PROJECTION_SIZE_SUM =
45             {String.format("sum(%s)", FileEntry.Columns.SIZE)};
46     private static final String FREESPACE_PROJECTION[] = {
47             FileEntry.Columns.ID, FileEntry.Columns.FILENAME,
48             FileEntry.Columns.CONTENT_URL, FileEntry.Columns.SIZE};
49     private static final String FREESPACE_ORDER_BY =
50             String.format("%s ASC", FileEntry.Columns.LAST_ACCESS);
51 
52     private final LruCache<String, CacheEntry> mEntryMap =
53             new LruCache<String, CacheEntry>(LRU_CAPACITY);
54 
55     private File mRootDir;
56     private long mCapacity;
57     private boolean mInitialized = false;
58     private long mTotalBytes;
59 
60     private DatabaseHelper mDbHelper;
61 
62     public static final class CacheEntry {
63         private long id;
64         public String contentUrl;
65         public File cacheFile;
66 
CacheEntry(long id, String contentUrl, File cacheFile)67         private CacheEntry(long id, String contentUrl, File cacheFile) {
68             this.id = id;
69             this.contentUrl = contentUrl;
70             this.cacheFile = cacheFile;
71         }
72     }
73 
deleteFiles(Context context, File rootDir, String dbName)74     public static void deleteFiles(Context context, File rootDir, String dbName) {
75         try {
76             context.getDatabasePath(dbName).delete();
77             File[] files = rootDir.listFiles();
78             if (files == null) return;
79             for (File file : rootDir.listFiles()) {
80                 String name = file.getName();
81                 if (file.isFile() && name.startsWith(FILE_PREFIX)
82                         && name.endsWith(FILE_POSTFIX)) file.delete();
83             }
84         } catch (Throwable t) {
85             Log.w(TAG, "cannot reset database", t);
86         }
87     }
88 
FileCache(Context context, File rootDir, String dbName, long capacity)89     public FileCache(Context context, File rootDir, String dbName, long capacity) {
90         mRootDir = Utils.checkNotNull(rootDir);
91         mCapacity = capacity;
92         mDbHelper = new DatabaseHelper(context, dbName);
93     }
94 
95     @Override
close()96     public void close() {
97         mDbHelper.close();
98     }
99 
store(String downloadUrl, File file)100     public void store(String downloadUrl, File file) {
101         if (!mInitialized) initialize();
102 
103         Utils.assertTrue(file.getParentFile().equals(mRootDir));
104         FileEntry entry = new FileEntry();
105         entry.hashCode = Utils.crc64Long(downloadUrl);
106         entry.contentUrl = downloadUrl;
107         entry.filename = file.getName();
108         entry.size = file.length();
109         entry.lastAccess = System.currentTimeMillis();
110         if (entry.size >= mCapacity) {
111             file.delete();
112             throw new IllegalArgumentException("file too large: " + entry.size);
113         }
114         synchronized (this) {
115             FileEntry original = queryDatabase(downloadUrl);
116             if (original != null) {
117                 file.delete();
118                 entry.filename = original.filename;
119                 entry.size = original.size;
120             } else {
121                 mTotalBytes += entry.size;
122             }
123             FileEntry.SCHEMA.insertOrReplace(
124                     mDbHelper.getWritableDatabase(), entry);
125             if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
126         }
127     }
128 
lookup(String downloadUrl)129     public CacheEntry lookup(String downloadUrl) {
130         if (!mInitialized) initialize();
131         CacheEntry entry;
132         synchronized (mEntryMap) {
133             entry = mEntryMap.get(downloadUrl);
134         }
135 
136         if (entry != null) {
137             synchronized (this) {
138                 updateLastAccess(entry.id);
139             }
140             return entry;
141         }
142 
143         synchronized (this) {
144             FileEntry file = queryDatabase(downloadUrl);
145             if (file == null) return null;
146             entry = new CacheEntry(
147                     file.id, downloadUrl, new File(mRootDir, file.filename));
148             if (!entry.cacheFile.isFile()) { // file has been removed
149                 try {
150                     mDbHelper.getWritableDatabase().delete(
151                             TABLE_NAME, ID_WHERE, new String[] {String.valueOf(file.id)});
152                     mTotalBytes -= file.size;
153                 } catch (Throwable t) {
154                     Log.w(TAG, "cannot delete entry: " + file.filename, t);
155                 }
156                 return null;
157             }
158             synchronized (mEntryMap) {
159                 mEntryMap.put(downloadUrl, entry);
160             }
161             return entry;
162         }
163     }
164 
queryDatabase(String downloadUrl)165     private FileEntry queryDatabase(String downloadUrl) {
166         long hash = Utils.crc64Long(downloadUrl);
167         String whereArgs[] = new String[] {String.valueOf(hash), downloadUrl};
168         Cursor cursor = mDbHelper.getReadableDatabase().query(TABLE_NAME,
169                 FileEntry.SCHEMA.getProjection(),
170                 QUERY_WHERE, whereArgs, null, null, null);
171         try {
172             if (!cursor.moveToNext()) return null;
173             FileEntry entry = new FileEntry();
174             FileEntry.SCHEMA.cursorToObject(cursor, entry);
175             updateLastAccess(entry.id);
176             return entry;
177         } finally {
178             cursor.close();
179         }
180     }
181 
updateLastAccess(long id)182     private void updateLastAccess(long id) {
183         ContentValues values = new ContentValues();
184         values.put(FileEntry.Columns.LAST_ACCESS, System.currentTimeMillis());
185         mDbHelper.getWritableDatabase().update(TABLE_NAME,
186                 values,  ID_WHERE, new String[] {String.valueOf(id)});
187     }
188 
createFile()189     public File createFile() throws IOException {
190         return File.createTempFile(FILE_PREFIX, FILE_POSTFIX, mRootDir);
191     }
192 
initialize()193     private synchronized void initialize() {
194         if (mInitialized) return;
195 
196         if (!mRootDir.isDirectory()) {
197             mRootDir.mkdirs();
198             if (!mRootDir.isDirectory()) {
199                 throw new RuntimeException("cannot create: " + mRootDir.getAbsolutePath());
200             }
201         }
202 
203         Cursor cursor = mDbHelper.getReadableDatabase().query(
204                 TABLE_NAME, PROJECTION_SIZE_SUM,
205                 null, null, null, null, null);
206         try {
207             if (cursor.moveToNext()) mTotalBytes = cursor.getLong(0);
208         } finally {
209             cursor.close();
210         }
211         if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
212 
213         // Mark initialized when everything above went through. If an exception was thrown,
214         // initialize() will be retried later.
215         mInitialized = true;
216     }
217 
freeSomeSpaceIfNeed(int maxDeleteFileCount)218     private void freeSomeSpaceIfNeed(int maxDeleteFileCount) {
219         Cursor cursor = mDbHelper.getReadableDatabase().query(
220                 TABLE_NAME, FREESPACE_PROJECTION,
221                 null, null, null, null, FREESPACE_ORDER_BY);
222         try {
223             while (maxDeleteFileCount > 0
224                     && mTotalBytes > mCapacity && cursor.moveToNext()) {
225                 long id = cursor.getLong(0);
226                 String path = cursor.getString(1);
227                 String url = cursor.getString(2);
228                 long size = cursor.getLong(3);
229 
230                 synchronized (mEntryMap) {
231                     // if some one still uses it
232                     if (mEntryMap.containsKey(url)) continue;
233                 }
234 
235                 --maxDeleteFileCount;
236                 if (new File(mRootDir, path).delete()) {
237                     mTotalBytes -= size;
238                     mDbHelper.getWritableDatabase().delete(TABLE_NAME,
239                             ID_WHERE, new String[]{String.valueOf(id)});
240                 } else {
241                     Log.w(TAG, "unable to delete file: " + path);
242                 }
243             }
244         } finally {
245             cursor.close();
246         }
247     }
248 
249     @Table("files")
250     private static class FileEntry extends Entry {
251         public static final EntrySchema SCHEMA = new EntrySchema(FileEntry.class);
252 
253         public interface Columns extends Entry.Columns {
254             public static final String HASH_CODE = "hash_code";
255             public static final String CONTENT_URL = "content_url";
256             public static final String FILENAME = "filename";
257             public static final String SIZE = "size";
258             public static final String LAST_ACCESS = "last_access";
259         }
260 
261         @Column(value = Columns.HASH_CODE, indexed = true)
262         public long hashCode;
263 
264         @Column(Columns.CONTENT_URL)
265         public String contentUrl;
266 
267         @Column(Columns.FILENAME)
268         public String filename;
269 
270         @Column(Columns.SIZE)
271         public long size;
272 
273         @Column(value = Columns.LAST_ACCESS, indexed = true)
274         public long lastAccess;
275 
276         @Override
toString()277         public String toString() {
278             return new StringBuilder()
279                     .append("hash_code: ").append(hashCode).append(", ")
280                     .append("content_url").append(contentUrl).append(", ")
281                     .append("last_access").append(lastAccess).append(", ")
282                     .append("filename").append(filename).toString();
283         }
284     }
285 
286     private final class DatabaseHelper extends SQLiteOpenHelper {
287         public static final int DATABASE_VERSION = 1;
288 
DatabaseHelper(Context context, String dbName)289         public DatabaseHelper(Context context, String dbName) {
290             super(context, dbName, null, DATABASE_VERSION);
291         }
292 
293         @Override
onCreate(SQLiteDatabase db)294         public void onCreate(SQLiteDatabase db) {
295             FileEntry.SCHEMA.createTables(db);
296 
297             // delete old files
298             for (File file : mRootDir.listFiles()) {
299                 if (!file.delete()) {
300                     Log.w(TAG, "fail to remove: " + file.getAbsolutePath());
301                 }
302             }
303         }
304 
305         @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)306         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
307             //reset everything
308             FileEntry.SCHEMA.dropTables(db);
309             onCreate(db);
310         }
311     }
312 }
313