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
17package com.android.gallery3d.common;
18
19import android.content.ContentValues;
20import android.content.Context;
21import android.database.Cursor;
22import android.database.sqlite.SQLiteDatabase;
23import android.database.sqlite.SQLiteOpenHelper;
24import android.util.Log;
25
26import com.android.gallery3d.common.Entry.Table;
27
28import java.io.Closeable;
29import java.io.File;
30import java.io.IOException;
31
32public 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
67        private CacheEntry(long id, String contentUrl, File cacheFile) {
68            this.id = id;
69            this.contentUrl = contentUrl;
70            this.cacheFile = cacheFile;
71        }
72    }
73
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
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
96    public void close() {
97        mDbHelper.close();
98    }
99
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
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
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
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
189    public File createFile() throws IOException {
190        return File.createTempFile(FILE_PREFIX, FILE_POSTFIX, mRootDir);
191    }
192
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
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
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
289        public DatabaseHelper(Context context, String dbName) {
290            super(context, dbName, null, DATABASE_VERSION);
291        }
292
293        @Override
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
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