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
195        if (!mRootDir.isDirectory()) {
196            mRootDir.mkdirs();
197            if (!mRootDir.isDirectory()) {
198                throw new RuntimeException("cannot create: " + mRootDir.getAbsolutePath());
199            }
200        }
201
202        Cursor cursor = mDbHelper.getReadableDatabase().query(
203                TABLE_NAME, PROJECTION_SIZE_SUM,
204                null, null, null, null, null);
205        try {
206            if (cursor.moveToNext()) mTotalBytes = cursor.getLong(0);
207        } finally {
208            cursor.close();
209        }
210        if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
211
212        // Mark initialized when everything above went through. If an exception was thrown,
213        // initialize() will be retried later.
214        mInitialized = true;
215    }
216
217    private void freeSomeSpaceIfNeed(int maxDeleteFileCount) {
218        Cursor cursor = mDbHelper.getReadableDatabase().query(
219                TABLE_NAME, FREESPACE_PROJECTION,
220                null, null, null, null, FREESPACE_ORDER_BY);
221        try {
222            while (maxDeleteFileCount > 0
223                    && mTotalBytes > mCapacity && cursor.moveToNext()) {
224                long id = cursor.getLong(0);
225                String path = cursor.getString(1);
226                String url = cursor.getString(2);
227                long size = cursor.getLong(3);
228
229                synchronized (mEntryMap) {
230                    // if some one still uses it
231                    if (mEntryMap.containsKey(url)) continue;
232                }
233
234                --maxDeleteFileCount;
235                if (new File(mRootDir, path).delete()) {
236                    mTotalBytes -= size;
237                    mDbHelper.getWritableDatabase().delete(TABLE_NAME,
238                            ID_WHERE, new String[]{String.valueOf(id)});
239                } else {
240                    Log.w(TAG, "unable to delete file: " + path);
241                }
242            }
243        } finally {
244            cursor.close();
245        }
246    }
247
248    @Table("files")
249    private static class FileEntry extends Entry {
250        public static final EntrySchema SCHEMA = new EntrySchema(FileEntry.class);
251
252        public interface Columns extends Entry.Columns {
253            public static final String HASH_CODE = "hash_code";
254            public static final String CONTENT_URL = "content_url";
255            public static final String FILENAME = "filename";
256            public static final String SIZE = "size";
257            public static final String LAST_ACCESS = "last_access";
258        }
259
260        @Column(value = Columns.HASH_CODE, indexed = true)
261        public long hashCode;
262
263        @Column(Columns.CONTENT_URL)
264        public String contentUrl;
265
266        @Column(Columns.FILENAME)
267        public String filename;
268
269        @Column(Columns.SIZE)
270        public long size;
271
272        @Column(value = Columns.LAST_ACCESS, indexed = true)
273        public long lastAccess;
274
275        @Override
276        public String toString() {
277            return new StringBuilder()
278                    .append("hash_code: ").append(hashCode).append(", ")
279                    .append("content_url").append(contentUrl).append(", ")
280                    .append("last_access").append(lastAccess).append(", ")
281                    .append("filename").append(filename).toString();
282        }
283    }
284
285    private final class DatabaseHelper extends SQLiteOpenHelper {
286        public static final int DATABASE_VERSION = 1;
287
288        public DatabaseHelper(Context context, String dbName) {
289            super(context, dbName, null, DATABASE_VERSION);
290        }
291
292        @Override
293        public void onCreate(SQLiteDatabase db) {
294            FileEntry.SCHEMA.createTables(db);
295
296            // delete old files
297            for (File file : mRootDir.listFiles()) {
298                if (!file.delete()) {
299                    Log.w(TAG, "fail to remove: " + file.getAbsolutePath());
300                }
301            }
302        }
303
304        @Override
305        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
306            //reset everything
307            FileEntry.SCHEMA.dropTables(db);
308            onCreate(db);
309        }
310    }
311}
312