1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * 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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License
15 */
16package com.android.providers.contacts;
17
18import android.content.ContentValues;
19import android.database.sqlite.SQLiteDatabase;
20import android.graphics.Bitmap;
21import android.provider.ContactsContract.PhotoFiles;
22import android.util.Log;
23
24import com.android.providers.contacts.ContactsDatabaseHelper.PhotoFilesColumns;
25import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
26import com.google.common.annotations.VisibleForTesting;
27
28import java.io.File;
29import java.io.FileOutputStream;
30import java.io.IOException;
31import java.util.HashMap;
32import java.util.HashSet;
33import java.util.Map;
34import java.util.Set;
35
36/**
37 * Photo storage system that stores the files directly onto the hard disk
38 * in the specified directory.
39 */
40public class PhotoStore {
41
42    private static final Object MKDIRS_LOCK = new Object();
43
44    private final String TAG = PhotoStore.class.getSimpleName();
45
46    // Directory name under the root directory for photo storage.
47    private final String DIRECTORY = "photos";
48
49    /** Map of keys to entries in the directory. */
50    private final Map<Long, Entry> mEntries;
51
52    /** Total amount of space currently used by the photo store in bytes. */
53    private long mTotalSize = 0;
54
55    /** The file path for photo storage. */
56    private final File mStorePath;
57
58    /** The database helper. */
59    private final ContactsDatabaseHelper mDatabaseHelper;
60
61    /** The database to use for storing metadata for the photo files. */
62    private SQLiteDatabase mDb;
63
64    /**
65     * Constructs an instance of the PhotoStore under the specified directory.
66     * @param rootDirectory The root directory of the storage.
67     * @param databaseHelper Helper class for obtaining a database instance.
68     */
69    public PhotoStore(File rootDirectory, ContactsDatabaseHelper databaseHelper) {
70        mStorePath = new File(rootDirectory, DIRECTORY);
71        synchronized (MKDIRS_LOCK) {
72            if (!mStorePath.exists()) {
73                if (!mStorePath.mkdirs()) {
74                    throw new RuntimeException("Unable to create photo storage directory "
75                            + mStorePath.getPath());
76                }
77            }
78        }
79        mDatabaseHelper = databaseHelper;
80        mEntries = new HashMap<Long, Entry>();
81        initialize();
82    }
83
84    /**
85     * Clears the photo storage. Deletes all files from disk.
86     */
87    public void clear() {
88        File[] files = mStorePath.listFiles();
89        if (files != null) {
90            for (File file : files) {
91                cleanupFile(file);
92            }
93        }
94        if (mDb == null) {
95            mDb = mDatabaseHelper.getWritableDatabase();
96        }
97        mDb.delete(Tables.PHOTO_FILES, null, null);
98        mEntries.clear();
99        mTotalSize = 0;
100    }
101
102    @VisibleForTesting
103    public long getTotalSize() {
104        return mTotalSize;
105    }
106
107    /**
108     * Returns the entry with the specified key if it exists, null otherwise.
109     */
110    public Entry get(long key) {
111        return mEntries.get(key);
112    }
113
114    /**
115     * Initializes the PhotoStore by scanning for all files currently in the
116     * specified root directory.
117     */
118    public final void initialize() {
119        File[] files = mStorePath.listFiles();
120        if (files == null) {
121            return;
122        }
123        for (File file : files) {
124            try {
125                Entry entry = new Entry(file);
126                putEntry(entry.id, entry);
127            } catch (NumberFormatException nfe) {
128                // Not a valid photo store entry - delete the file.
129                cleanupFile(file);
130            }
131        }
132
133        // Get a reference to the database.
134        mDb = mDatabaseHelper.getWritableDatabase();
135    }
136
137    /**
138     * Cleans up the photo store such that only the keys in use still remain as
139     * entries in the store (all other entries are deleted).
140     *
141     * If an entry in the keys in use does not exist in the photo store, that key
142     * will be returned in the result set - the caller should take steps to clean
143     * up those references, as the underlying photo entries do not exist.
144     *
145     * @param keysInUse The set of all keys that are in use in the photo store.
146     * @return The set of the keys in use that refer to non-existent entries.
147     */
148    public Set<Long> cleanup(Set<Long> keysInUse) {
149        Set<Long> keysToRemove = new HashSet<Long>();
150        keysToRemove.addAll(mEntries.keySet());
151        keysToRemove.removeAll(keysInUse);
152        if (!keysToRemove.isEmpty()) {
153            Log.d(TAG, "cleanup removing " + keysToRemove.size() + " entries");
154            for (long key : keysToRemove) {
155                remove(key);
156            }
157        }
158
159        Set<Long> missingKeys = new HashSet<Long>();
160        missingKeys.addAll(keysInUse);
161        missingKeys.removeAll(mEntries.keySet());
162        return missingKeys;
163    }
164
165    /**
166     * Inserts the photo in the given photo processor into the photo store.  If the display photo
167     * is already thumbnail-sized or smaller, this will do nothing (and will return 0).
168     * @param photoProcessor A photo processor containing the photo data to insert.
169     * @return The photo file ID associated with the file, or 0 if the file could not be created or
170     *     is thumbnail-sized or smaller.
171     */
172    public long insert(PhotoProcessor photoProcessor) {
173        return insert(photoProcessor, false);
174    }
175
176    /**
177     * Inserts the photo in the given photo processor into the photo store.  If the display photo
178     * is already thumbnail-sized or smaller, this will do nothing (and will return 0) unless
179     * allowSmallImageStorage is specified.
180     * @param photoProcessor A photo processor containing the photo data to insert.
181     * @param allowSmallImageStorage Whether thumbnail-sized or smaller photos should still be
182     *     stored in the file store.
183     * @return The photo file ID associated with the file, or 0 if the file could not be created or
184     *     is thumbnail-sized or smaller and allowSmallImageStorage is false.
185     */
186    public long insert(PhotoProcessor photoProcessor, boolean allowSmallImageStorage) {
187        Bitmap displayPhoto = photoProcessor.getDisplayPhoto();
188        int width = displayPhoto.getWidth();
189        int height = displayPhoto.getHeight();
190        int thumbnailDim = photoProcessor.getMaxThumbnailPhotoDim();
191        if (allowSmallImageStorage || width > thumbnailDim || height > thumbnailDim) {
192            // Write the photo to a temp file, create the DB record for tracking it, and rename the
193            // temp file to match.
194            File file = null;
195            try {
196                // Write the display photo to a temp file.
197                byte[] photoBytes = photoProcessor.getDisplayPhotoBytes();
198                file = File.createTempFile("img", null, mStorePath);
199                FileOutputStream fos = new FileOutputStream(file);
200                fos.write(photoBytes);
201                fos.close();
202
203                // Create the DB entry.
204                ContentValues values = new ContentValues();
205                values.put(PhotoFiles.HEIGHT, height);
206                values.put(PhotoFiles.WIDTH, width);
207                values.put(PhotoFiles.FILESIZE, photoBytes.length);
208                long id = mDb.insert(Tables.PHOTO_FILES, null, values);
209                if (id != 0) {
210                    // Rename the temp file.
211                    File target = getFileForPhotoFileId(id);
212                    if (file.renameTo(target)) {
213                        Entry entry = new Entry(target);
214                        putEntry(entry.id, entry);
215                        return id;
216                    }
217                }
218            } catch (IOException e) {
219                // Write failed - will delete the file below.
220            }
221
222            // If anything went wrong, clean up the file before returning.
223            if (file != null) {
224                cleanupFile(file);
225            }
226        }
227        return 0;
228    }
229
230    private void cleanupFile(File file) {
231        boolean deleted = file.delete();
232        if (!deleted) {
233            Log.d("Could not clean up file %s", file.getAbsolutePath());
234        }
235    }
236
237    /**
238     * Removes the specified photo file from the store if it exists.
239     */
240    public void remove(long id) {
241        cleanupFile(getFileForPhotoFileId(id));
242        removeEntry(id);
243    }
244
245    /**
246     * Returns a file object for the given photo file ID.
247     */
248    private File getFileForPhotoFileId(long id) {
249        return new File(mStorePath, String.valueOf(id));
250    }
251
252    /**
253     * Puts the entry with the specified photo file ID into the store.
254     * @param id The photo file ID to identify the entry by.
255     * @param entry The entry to store.
256     */
257    private void putEntry(long id, Entry entry) {
258        if (!mEntries.containsKey(id)) {
259            mTotalSize += entry.size;
260        } else {
261            Entry oldEntry = mEntries.get(id);
262            mTotalSize += (entry.size - oldEntry.size);
263        }
264        mEntries.put(id, entry);
265    }
266
267    /**
268     * Removes the entry identified by the given photo file ID from the store, removing
269     * the associated photo file entry from the database.
270     */
271    private void removeEntry(long id) {
272        Entry entry = mEntries.get(id);
273        if (entry != null) {
274            mTotalSize -= entry.size;
275            mEntries.remove(id);
276        }
277        mDb.delete(ContactsDatabaseHelper.Tables.PHOTO_FILES, PhotoFilesColumns.CONCRETE_ID + "=?",
278                new String[]{String.valueOf(id)});
279    }
280
281    public static class Entry {
282        /** The photo file ID that identifies the entry. */
283        public final long id;
284
285        /** The size of the data, in bytes. */
286        public final long size;
287
288        /** The path to the file. */
289        public final String path;
290
291        public Entry(File file) {
292            id = Long.parseLong(file.getName());
293            size = file.length();
294            path = file.getAbsolutePath();
295        }
296    }
297}
298