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    public void close() {
96        mDbHelper.close();
97    }
98
99    public void store(String downloadUrl, File file) {
100        if (!mInitialized) initialize();
101
102        Utils.assertTrue(file.getParentFile().equals(mRootDir));
103        FileEntry entry = new FileEntry();
104        entry.hashCode = Utils.crc64Long(downloadUrl);
105        entry.contentUrl = downloadUrl;
106        entry.filename = file.getName();
107        entry.size = file.length();
108        entry.lastAccess = System.currentTimeMillis();
109        if (entry.size >= mCapacity) {
110            file.delete();
111            throw new IllegalArgumentException("file too large: " + entry.size);
112        }
113        synchronized (this) {
114            FileEntry original = queryDatabase(downloadUrl);
115            if (original != null) {
116                file.delete();
117                entry.filename = original.filename;
118                entry.size = original.size;
119            } else {
120                mTotalBytes += entry.size;
121            }
122            FileEntry.SCHEMA.insertOrReplace(
123                    mDbHelper.getWritableDatabase(), entry);
124            if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
125        }
126    }
127
128    public CacheEntry lookup(String downloadUrl) {
129        if (!mInitialized) initialize();
130        CacheEntry entry;
131        synchronized (mEntryMap) {
132            entry = mEntryMap.get(downloadUrl);
133        }
134
135        if (entry != null) {
136            synchronized (this) {
137                updateLastAccess(entry.id);
138            }
139            return entry;
140        }
141
142        synchronized (this) {
143            FileEntry file = queryDatabase(downloadUrl);
144            if (file == null) return null;
145            entry = new CacheEntry(
146                    file.id, downloadUrl, new File(mRootDir, file.filename));
147            if (!entry.cacheFile.isFile()) { // file has been removed
148                try {
149                    mDbHelper.getWritableDatabase().delete(
150                            TABLE_NAME, ID_WHERE, new String[] {String.valueOf(file.id)});
151                    mTotalBytes -= file.size;
152                } catch (Throwable t) {
153                    Log.w(TAG, "cannot delete entry: " + file.filename, t);
154                }
155                return null;
156            }
157            synchronized (mEntryMap) {
158                mEntryMap.put(downloadUrl, entry);
159            }
160            return entry;
161        }
162    }
163
164    private FileEntry queryDatabase(String downloadUrl) {
165        long hash = Utils.crc64Long(downloadUrl);
166        String whereArgs[] = new String[] {String.valueOf(hash), downloadUrl};
167        Cursor cursor = mDbHelper.getReadableDatabase().query(TABLE_NAME,
168                FileEntry.SCHEMA.getProjection(),
169                QUERY_WHERE, whereArgs, null, null, null);
170        try {
171            if (!cursor.moveToNext()) return null;
172            FileEntry entry = new FileEntry();
173            FileEntry.SCHEMA.cursorToObject(cursor, entry);
174            updateLastAccess(entry.id);
175            return entry;
176        } finally {
177            cursor.close();
178        }
179    }
180
181    private void updateLastAccess(long id) {
182        ContentValues values = new ContentValues();
183        values.put(FileEntry.Columns.LAST_ACCESS, System.currentTimeMillis());
184        mDbHelper.getWritableDatabase().update(TABLE_NAME,
185                values,  ID_WHERE, new String[] {String.valueOf(id)});
186    }
187
188    public File createFile() throws IOException {
189        return File.createTempFile(FILE_PREFIX, FILE_POSTFIX, mRootDir);
190    }
191
192    private synchronized void initialize() {
193        if (mInitialized) return;
194        mInitialized = true;
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
214    private void freeSomeSpaceIfNeed(int maxDeleteFileCount) {
215        Cursor cursor = mDbHelper.getReadableDatabase().query(
216                TABLE_NAME, FREESPACE_PROJECTION,
217                null, null, null, null, FREESPACE_ORDER_BY);
218        try {
219            while (maxDeleteFileCount > 0
220                    && mTotalBytes > mCapacity && cursor.moveToNext()) {
221                long id = cursor.getLong(0);
222                String path = cursor.getString(1);
223                String url = cursor.getString(2);
224                long size = cursor.getLong(3);
225
226                synchronized (mEntryMap) {
227                    // if some one still uses it
228                    if (mEntryMap.containsKey(url)) continue;
229                }
230
231                --maxDeleteFileCount;
232                if (new File(mRootDir, path).delete()) {
233                    mTotalBytes -= size;
234                    mDbHelper.getWritableDatabase().delete(TABLE_NAME,
235                            ID_WHERE, new String[]{String.valueOf(id)});
236                } else {
237                    Log.w(TAG, "unable to delete file: " + path);
238                }
239            }
240        } finally {
241            cursor.close();
242        }
243    }
244
245    @Table("files")
246    private static class FileEntry extends Entry {
247        public static final EntrySchema SCHEMA = new EntrySchema(FileEntry.class);
248
249        public interface Columns extends Entry.Columns {
250            public static final String HASH_CODE = "hash_code";
251            public static final String CONTENT_URL = "content_url";
252            public static final String FILENAME = "filename";
253            public static final String SIZE = "size";
254            public static final String LAST_ACCESS = "last_access";
255        }
256
257        @Column(value = Columns.HASH_CODE, indexed = true)
258        public long hashCode;
259
260        @Column(Columns.CONTENT_URL)
261        public String contentUrl;
262
263        @Column(Columns.FILENAME)
264        public String filename;
265
266        @Column(Columns.SIZE)
267        public long size;
268
269        @Column(value = Columns.LAST_ACCESS, indexed = true)
270        public long lastAccess;
271
272        @Override
273        public String toString() {
274            return new StringBuilder()
275                    .append("hash_code: ").append(hashCode).append(", ")
276                    .append("content_url").append(contentUrl).append(", ")
277                    .append("last_access").append(lastAccess).append(", ")
278                    .append("filename").append(filename).toString();
279        }
280    }
281
282    private final class DatabaseHelper extends SQLiteOpenHelper {
283        public static final int DATABASE_VERSION = 1;
284
285        public DatabaseHelper(Context context, String dbName) {
286            super(context, dbName, null, DATABASE_VERSION);
287        }
288
289        @Override
290        public void onCreate(SQLiteDatabase db) {
291            FileEntry.SCHEMA.createTables(db);
292
293            // delete old files
294            for (File file : mRootDir.listFiles()) {
295                if (!file.delete()) {
296                    Log.w(TAG, "fail to remove: " + file.getAbsolutePath());
297                }
298            }
299        }
300
301        @Override
302        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
303            //reset everything
304            FileEntry.SCHEMA.dropTables(db);
305            onCreate(db);
306        }
307    }
308}
309