1/*
2 * Copyright (C) 2010 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.data;
18
19import android.content.ContentValues;
20import android.content.Context;
21import android.database.Cursor;
22import android.database.sqlite.SQLiteDatabase;
23import android.database.sqlite.SQLiteOpenHelper;
24
25import com.android.gallery3d.app.GalleryApp;
26import com.android.gallery3d.common.LruCache;
27import com.android.gallery3d.common.Utils;
28import com.android.gallery3d.data.DownloadEntry.Columns;
29import com.android.gallery3d.util.Future;
30import com.android.gallery3d.util.FutureListener;
31import com.android.gallery3d.util.ThreadPool;
32import com.android.gallery3d.util.ThreadPool.CancelListener;
33import com.android.gallery3d.util.ThreadPool.Job;
34import com.android.gallery3d.util.ThreadPool.JobContext;
35
36import java.io.File;
37import java.net.URL;
38import java.util.HashMap;
39import java.util.HashSet;
40
41public class DownloadCache {
42    private static final String TAG = "DownloadCache";
43    private static final int MAX_DELETE_COUNT = 16;
44    private static final int LRU_CAPACITY = 4;
45
46    private static final String TABLE_NAME = DownloadEntry.SCHEMA.getTableName();
47
48    private static final String QUERY_PROJECTION[] = {Columns.ID, Columns.DATA};
49    private static final String WHERE_HASH_AND_URL = String.format(
50            "%s = ? AND %s = ?", Columns.HASH_CODE, Columns.CONTENT_URL);
51    private static final int QUERY_INDEX_ID = 0;
52    private static final int QUERY_INDEX_DATA = 1;
53
54    private static final String FREESPACE_PROJECTION[] = {
55            Columns.ID, Columns.DATA, Columns.CONTENT_URL, Columns.CONTENT_SIZE};
56    private static final String FREESPACE_ORDER_BY =
57            String.format("%s ASC", Columns.LAST_ACCESS);
58    private static final int FREESPACE_IDNEX_ID = 0;
59    private static final int FREESPACE_IDNEX_DATA = 1;
60    private static final int FREESPACE_INDEX_CONTENT_URL = 2;
61    private static final int FREESPACE_INDEX_CONTENT_SIZE = 3;
62
63    private static final String ID_WHERE = Columns.ID + " = ?";
64
65    private static final String SUM_PROJECTION[] =
66            {String.format("sum(%s)", Columns.CONTENT_SIZE)};
67    private static final int SUM_INDEX_SUM = 0;
68
69    private final LruCache<String, Entry> mEntryMap =
70            new LruCache<String, Entry>(LRU_CAPACITY);
71    private final HashMap<String, DownloadTask> mTaskMap =
72            new HashMap<String, DownloadTask>();
73    private final File mRoot;
74    private final GalleryApp mApplication;
75    private final SQLiteDatabase mDatabase;
76    private final long mCapacity;
77
78    private long mTotalBytes = 0;
79    private boolean mInitialized = false;
80
81    public DownloadCache(GalleryApp application, File root, long capacity) {
82        mRoot = Utils.checkNotNull(root);
83        mApplication = Utils.checkNotNull(application);
84        mCapacity = capacity;
85        mDatabase = new DatabaseHelper(application.getAndroidContext())
86                .getWritableDatabase();
87    }
88
89    private Entry findEntryInDatabase(String stringUrl) {
90        long hash = Utils.crc64Long(stringUrl);
91        String whereArgs[] = {String.valueOf(hash), stringUrl};
92        Cursor cursor = mDatabase.query(TABLE_NAME, QUERY_PROJECTION,
93                WHERE_HASH_AND_URL, whereArgs, null, null, null);
94        try {
95            if (cursor.moveToNext()) {
96                File file = new File(cursor.getString(QUERY_INDEX_DATA));
97                long id = cursor.getInt(QUERY_INDEX_ID);
98                Entry entry = null;
99                synchronized (mEntryMap) {
100                    entry = mEntryMap.get(stringUrl);
101                    if (entry == null) {
102                        entry = new Entry(id, file);
103                        mEntryMap.put(stringUrl, entry);
104                    }
105                }
106                return entry;
107            }
108        } finally {
109            cursor.close();
110        }
111        return null;
112    }
113
114    public Entry download(JobContext jc, URL url) {
115        if (!mInitialized) initialize();
116
117        String stringUrl = url.toString();
118
119        // First find in the entry-pool
120        synchronized (mEntryMap) {
121            Entry entry = mEntryMap.get(stringUrl);
122            if (entry != null) {
123                updateLastAccess(entry.mId);
124                return entry;
125            }
126        }
127
128        // Then, find it in database
129        TaskProxy proxy = new TaskProxy();
130        synchronized (mTaskMap) {
131            Entry entry = findEntryInDatabase(stringUrl);
132            if (entry != null) {
133                updateLastAccess(entry.mId);
134                return entry;
135            }
136
137            // Finally, we need to download the file ....
138            // First check if we are downloading it now ...
139            DownloadTask task = mTaskMap.get(stringUrl);
140            if (task == null) { // if not, start the download task now
141                task = new DownloadTask(stringUrl);
142                mTaskMap.put(stringUrl, task);
143                task.mFuture = mApplication.getThreadPool().submit(task, task);
144            }
145            task.addProxy(proxy);
146        }
147
148        return proxy.get(jc);
149    }
150
151    private void updateLastAccess(long id) {
152        ContentValues values = new ContentValues();
153        values.put(Columns.LAST_ACCESS, System.currentTimeMillis());
154        mDatabase.update(TABLE_NAME, values,
155                ID_WHERE, new String[] {String.valueOf(id)});
156    }
157
158    private synchronized void freeSomeSpaceIfNeed(int maxDeleteFileCount) {
159        if (mTotalBytes <= mCapacity) return;
160        Cursor cursor = mDatabase.query(TABLE_NAME,
161                FREESPACE_PROJECTION, null, null, null, null, FREESPACE_ORDER_BY);
162        try {
163            while (maxDeleteFileCount > 0
164                    && mTotalBytes > mCapacity && cursor.moveToNext()) {
165                long id = cursor.getLong(FREESPACE_IDNEX_ID);
166                String url = cursor.getString(FREESPACE_INDEX_CONTENT_URL);
167                long size = cursor.getLong(FREESPACE_INDEX_CONTENT_SIZE);
168                String path = cursor.getString(FREESPACE_IDNEX_DATA);
169                boolean containsKey;
170                synchronized (mEntryMap) {
171                    containsKey = mEntryMap.containsKey(url);
172                }
173                if (!containsKey) {
174                    --maxDeleteFileCount;
175                    mTotalBytes -= size;
176                    new File(path).delete();
177                    mDatabase.delete(TABLE_NAME,
178                            ID_WHERE, new String[]{String.valueOf(id)});
179                } else {
180                    // skip delete, since it is being used
181                }
182            }
183        } finally {
184            cursor.close();
185        }
186    }
187
188    private synchronized long insertEntry(String url, File file) {
189        long size = file.length();
190        mTotalBytes += size;
191
192        ContentValues values = new ContentValues();
193        String hashCode = String.valueOf(Utils.crc64Long(url));
194        values.put(Columns.DATA, file.getAbsolutePath());
195        values.put(Columns.HASH_CODE, hashCode);
196        values.put(Columns.CONTENT_URL, url);
197        values.put(Columns.CONTENT_SIZE, size);
198        values.put(Columns.LAST_UPDATED, System.currentTimeMillis());
199        return mDatabase.insert(TABLE_NAME, "", values);
200    }
201
202    private synchronized void initialize() {
203        if (mInitialized) return;
204        mInitialized = true;
205        if (!mRoot.isDirectory()) mRoot.mkdirs();
206        if (!mRoot.isDirectory()) {
207            throw new RuntimeException("cannot create " + mRoot.getAbsolutePath());
208        }
209
210        Cursor cursor = mDatabase.query(
211                TABLE_NAME, SUM_PROJECTION, null, null, null, null, null);
212        mTotalBytes = 0;
213        try {
214            if (cursor.moveToNext()) {
215                mTotalBytes = cursor.getLong(SUM_INDEX_SUM);
216            }
217        } finally {
218            cursor.close();
219        }
220        if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
221    }
222
223    private final class DatabaseHelper extends SQLiteOpenHelper {
224        public static final String DATABASE_NAME = "download.db";
225        public static final int DATABASE_VERSION = 2;
226
227        public DatabaseHelper(Context context) {
228            super(context, DATABASE_NAME, null, DATABASE_VERSION);
229        }
230
231        @Override
232        public void onCreate(SQLiteDatabase db) {
233            DownloadEntry.SCHEMA.createTables(db);
234            // Delete old files
235            for (File file : mRoot.listFiles()) {
236                if (!file.delete()) {
237                    Log.w(TAG, "fail to remove: " + file.getAbsolutePath());
238                }
239            }
240        }
241
242        @Override
243        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
244            //reset everything
245            DownloadEntry.SCHEMA.dropTables(db);
246            onCreate(db);
247        }
248    }
249
250    public class Entry {
251        public File cacheFile;
252        protected long mId;
253
254        Entry(long id, File cacheFile) {
255            mId = id;
256            this.cacheFile = Utils.checkNotNull(cacheFile);
257        }
258    }
259
260    private class DownloadTask implements Job<File>, FutureListener<File> {
261        private HashSet<TaskProxy> mProxySet = new HashSet<TaskProxy>();
262        private Future<File> mFuture;
263        private final String mUrl;
264
265        public DownloadTask(String url) {
266            mUrl = Utils.checkNotNull(url);
267        }
268
269        public void removeProxy(TaskProxy proxy) {
270            synchronized (mTaskMap) {
271                Utils.assertTrue(mProxySet.remove(proxy));
272                if (mProxySet.isEmpty()) {
273                    mFuture.cancel();
274                    mTaskMap.remove(mUrl);
275                }
276            }
277        }
278
279        // should be used in synchronized block of mDatabase
280        public void addProxy(TaskProxy proxy) {
281            proxy.mTask = this;
282            mProxySet.add(proxy);
283        }
284
285        @Override
286        public void onFutureDone(Future<File> future) {
287            File file = future.get();
288            long id = 0;
289            if (file != null) { // insert to database
290                id = insertEntry(mUrl, file);
291            }
292
293            if (future.isCancelled()) {
294                Utils.assertTrue(mProxySet.isEmpty());
295                return;
296            }
297
298            synchronized (mTaskMap) {
299                Entry entry = null;
300                synchronized (mEntryMap) {
301                    if (file != null) {
302                        entry = new Entry(id, file);
303                        Utils.assertTrue(mEntryMap.put(mUrl, entry) == null);
304                    }
305                }
306                for (TaskProxy proxy : mProxySet) {
307                    proxy.setResult(entry);
308                }
309                mTaskMap.remove(mUrl);
310                freeSomeSpaceIfNeed(MAX_DELETE_COUNT);
311            }
312        }
313
314        @Override
315        public File run(JobContext jc) {
316            // TODO: utilize etag
317            jc.setMode(ThreadPool.MODE_NETWORK);
318            File tempFile = null;
319            try {
320                URL url = new URL(mUrl);
321                tempFile = File.createTempFile("cache", ".tmp", mRoot);
322                // download from url to tempFile
323                jc.setMode(ThreadPool.MODE_NETWORK);
324                boolean downloaded = DownloadUtils.requestDownload(jc, url, tempFile);
325                jc.setMode(ThreadPool.MODE_NONE);
326                if (downloaded) return tempFile;
327            } catch (Exception e) {
328                Log.e(TAG, String.format("fail to download %s", mUrl), e);
329            } finally {
330                jc.setMode(ThreadPool.MODE_NONE);
331            }
332            if (tempFile != null) tempFile.delete();
333            return null;
334        }
335    }
336
337    public static class TaskProxy {
338        private DownloadTask mTask;
339        private boolean mIsCancelled = false;
340        private Entry mEntry;
341
342        synchronized void setResult(Entry entry) {
343            if (mIsCancelled) return;
344            mEntry = entry;
345            notifyAll();
346        }
347
348        public synchronized Entry get(JobContext jc) {
349            jc.setCancelListener(new CancelListener() {
350                @Override
351                public void onCancel() {
352                    mTask.removeProxy(TaskProxy.this);
353                    synchronized (TaskProxy.this) {
354                        mIsCancelled = true;
355                        TaskProxy.this.notifyAll();
356                    }
357                }
358            });
359            while (!mIsCancelled && mEntry == null) {
360                try {
361                    wait();
362                } catch (InterruptedException e) {
363                    Log.w(TAG, "ignore interrupt", e);
364                }
365            }
366            jc.setCancelListener(null);
367            return mEntry;
368        }
369    }
370}
371