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