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