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