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.providers.downloads;
18
19import android.content.ContentUris;
20import android.content.Context;
21import android.content.res.Resources;
22import android.database.Cursor;
23import android.database.sqlite.SQLiteException;
24import android.net.Uri;
25import android.os.Environment;
26import android.os.StatFs;
27import android.provider.Downloads;
28import android.text.TextUtils;
29import android.util.Log;
30
31import com.android.internal.R;
32
33import java.io.File;
34import java.util.ArrayList;
35import java.util.Arrays;
36import java.util.List;
37
38/**
39 * Manages the storage space consumed by Downloads Data dir. When space falls below
40 * a threshold limit (set in resource xml files), starts cleanup of the Downloads data dir
41 * to free up space.
42 */
43class StorageManager {
44    /** the max amount of space allowed to be taken up by the downloads data dir */
45    private static final long sMaxdownloadDataDirSize =
46            Resources.getSystem().getInteger(R.integer.config_downloadDataDirSize) * 1024 * 1024;
47
48    /** threshold (in bytes) beyond which the low space warning kicks in and attempt is made to
49     * purge some downloaded files to make space
50     */
51    private static final long sDownloadDataDirLowSpaceThreshold =
52            Resources.getSystem().getInteger(
53                    R.integer.config_downloadDataDirLowSpaceThreshold)
54                    * sMaxdownloadDataDirSize / 100;
55
56    /** see {@link Environment#getExternalStorageDirectory()} */
57    private final File mExternalStorageDir;
58
59    /** see {@link Environment#getDownloadCacheDirectory()} */
60    private final File mSystemCacheDir;
61
62    /** The downloaded files are saved to this dir. it is the value returned by
63     * {@link Context#getCacheDir()}.
64     */
65    private final File mDownloadDataDir;
66
67    /** the Singleton instance of this class.
68     * TODO: once DownloadService is refactored into a long-living object, there is no need
69     * for this Singleton'ing.
70     */
71    private static StorageManager sSingleton = null;
72
73    /** how often do we need to perform checks on space to make sure space is available */
74    private static final int FREQUENCY_OF_CHECKS_ON_SPACE_AVAILABILITY = 1024 * 1024; // 1MB
75    private int mBytesDownloadedSinceLastCheckOnSpace = 0;
76
77    /** misc members */
78    private final Context mContext;
79
80    /**
81     * maintains Singleton instance of this class
82     */
83    synchronized static StorageManager getInstance(Context context) {
84        if (sSingleton == null) {
85            sSingleton = new StorageManager(context);
86        }
87        return sSingleton;
88    }
89
90    private StorageManager(Context context) { // constructor is private
91        mContext = context;
92        mDownloadDataDir = context.getCacheDir();
93        mExternalStorageDir = Environment.getExternalStorageDirectory();
94        mSystemCacheDir = Environment.getDownloadCacheDirectory();
95        startThreadToCleanupDatabaseAndPurgeFileSystem();
96    }
97
98    /** How often should database and filesystem be cleaned up to remove spurious files
99     * from the file system and
100     * The value is specified in terms of num of downloads since last time the cleanup was done.
101     */
102    private static final int FREQUENCY_OF_DATABASE_N_FILESYSTEM_CLEANUP = 250;
103    private int mNumDownloadsSoFar = 0;
104
105    synchronized void incrementNumDownloadsSoFar() {
106        if (++mNumDownloadsSoFar % FREQUENCY_OF_DATABASE_N_FILESYSTEM_CLEANUP == 0) {
107            startThreadToCleanupDatabaseAndPurgeFileSystem();
108        }
109    }
110    /* start a thread to cleanup the following
111     *      remove spurious files from the file system
112     *      remove excess entries from the database
113     */
114    private Thread mCleanupThread = null;
115    private synchronized void startThreadToCleanupDatabaseAndPurgeFileSystem() {
116        if (mCleanupThread != null && mCleanupThread.isAlive()) {
117            return;
118        }
119        mCleanupThread = new Thread() {
120            @Override public void run() {
121                removeSpuriousFiles();
122                trimDatabase();
123            }
124        };
125        mCleanupThread.start();
126    }
127
128    void verifySpaceBeforeWritingToFile(int destination, String path, long length)
129            throws StopRequestException {
130        // do this check only once for every 1MB of downloaded data
131        if (incrementBytesDownloadedSinceLastCheckOnSpace(length) <
132                FREQUENCY_OF_CHECKS_ON_SPACE_AVAILABILITY) {
133            return;
134        }
135        verifySpace(destination, path, length);
136    }
137
138    void verifySpace(int destination, String path, long length) throws StopRequestException {
139        resetBytesDownloadedSinceLastCheckOnSpace();
140        File dir = null;
141        if (Constants.LOGV) {
142            Log.i(Constants.TAG, "in verifySpace, destination: " + destination +
143                    ", path: " + path + ", length: " + length);
144        }
145        if (path == null) {
146            throw new IllegalArgumentException("path can't be null");
147        }
148        switch (destination) {
149            case Downloads.Impl.DESTINATION_CACHE_PARTITION:
150            case Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING:
151            case Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE:
152                dir = mDownloadDataDir;
153                break;
154            case Downloads.Impl.DESTINATION_EXTERNAL:
155                dir = mExternalStorageDir;
156                break;
157            case Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION:
158                dir = mSystemCacheDir;
159                break;
160            case Downloads.Impl.DESTINATION_FILE_URI:
161                if (path.startsWith(mExternalStorageDir.getPath())) {
162                    dir = mExternalStorageDir;
163                } else if (path.startsWith(mDownloadDataDir.getPath())) {
164                    dir = mDownloadDataDir;
165                } else if (path.startsWith(mSystemCacheDir.getPath())) {
166                    dir = mSystemCacheDir;
167                }
168                break;
169         }
170        if (dir == null) {
171            throw new IllegalStateException("invalid combination of destination: " + destination +
172                    ", path: " + path);
173        }
174        findSpace(dir, length, destination);
175    }
176
177    /**
178     * finds space in the given filesystem (input param: root) to accommodate # of bytes
179     * specified by the input param(targetBytes).
180     * returns true if found. false otherwise.
181     */
182    private synchronized void findSpace(File root, long targetBytes, int destination)
183            throws StopRequestException {
184        if (targetBytes == 0) {
185            return;
186        }
187        if (destination == Downloads.Impl.DESTINATION_FILE_URI ||
188                destination == Downloads.Impl.DESTINATION_EXTERNAL) {
189            if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
190                throw new StopRequestException(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR,
191                        "external media not mounted");
192            }
193        }
194        // is there enough space in the file system of the given param 'root'.
195        long bytesAvailable = getAvailableBytesInFileSystemAtGivenRoot(root);
196        if (bytesAvailable < sDownloadDataDirLowSpaceThreshold) {
197            /* filesystem's available space is below threshold for low space warning.
198             * threshold typically is 10% of download data dir space quota.
199             * try to cleanup and see if the low space situation goes away.
200             */
201            discardPurgeableFiles(destination, sDownloadDataDirLowSpaceThreshold);
202            removeSpuriousFiles();
203            bytesAvailable = getAvailableBytesInFileSystemAtGivenRoot(root);
204            if (bytesAvailable < sDownloadDataDirLowSpaceThreshold) {
205                /*
206                 * available space is still below the threshold limit.
207                 *
208                 * If this is system cache dir, print a warning.
209                 * otherwise, don't allow downloading until more space
210                 * is available because downloadmanager shouldn't end up taking those last
211                 * few MB of space left on the filesystem.
212                 */
213                if (root.equals(mSystemCacheDir)) {
214                    Log.w(Constants.TAG, "System cache dir ('/cache') is running low on space." +
215                            "space available (in bytes): " + bytesAvailable);
216                } else {
217                    throw new StopRequestException(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
218                            "space in the filesystem rooted at: " + root +
219                            " is below 10% availability. stopping this download.");
220                }
221            }
222        }
223        if (root.equals(mDownloadDataDir)) {
224            // this download is going into downloads data dir. check space in that specific dir.
225            bytesAvailable = getAvailableBytesInDownloadsDataDir(mDownloadDataDir);
226            if (bytesAvailable < sDownloadDataDirLowSpaceThreshold) {
227                // print a warning
228                Log.w(Constants.TAG, "Downloads data dir: " + root +
229                        " is running low on space. space available (in bytes): " + bytesAvailable);
230            }
231            if (bytesAvailable < targetBytes) {
232                // Insufficient space; make space.
233                discardPurgeableFiles(destination, sDownloadDataDirLowSpaceThreshold);
234                removeSpuriousFiles();
235                bytesAvailable = getAvailableBytesInDownloadsDataDir(mDownloadDataDir);
236            }
237        }
238        if (bytesAvailable < targetBytes) {
239            throw new StopRequestException(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
240                    "not enough free space in the filesystem rooted at: " + root +
241                    " and unable to free any more");
242        }
243    }
244
245    /**
246     * returns the number of bytes available in the downloads data dir
247     * TODO this implementation is too slow. optimize it.
248     */
249    private long getAvailableBytesInDownloadsDataDir(File root) {
250        File[] files = root.listFiles();
251        long space = sMaxdownloadDataDirSize;
252        if (files == null) {
253            return space;
254        }
255        int size = files.length;
256        for (int i = 0; i < size; i++) {
257            space -= files[i].length();
258        }
259        if (Constants.LOGV) {
260            Log.i(Constants.TAG, "available space (in bytes) in downloads data dir: " + space);
261        }
262        return space;
263    }
264
265    private long getAvailableBytesInFileSystemAtGivenRoot(File root) {
266        StatFs stat = new StatFs(root.getPath());
267        // put a bit of margin (in case creating the file grows the system by a few blocks)
268        long availableBlocks = (long) stat.getAvailableBlocks() - 4;
269        long size = stat.getBlockSize() * availableBlocks;
270        if (Constants.LOGV) {
271            Log.i(Constants.TAG, "available space (in bytes) in filesystem rooted at: " +
272                    root.getPath() + " is: " + size);
273        }
274        return size;
275    }
276
277    File locateDestinationDirectory(String mimeType, int destination, long contentLength)
278            throws StopRequestException {
279        switch (destination) {
280            case Downloads.Impl.DESTINATION_CACHE_PARTITION:
281            case Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE:
282            case Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING:
283                return mDownloadDataDir;
284            case Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION:
285                return mSystemCacheDir;
286            case Downloads.Impl.DESTINATION_EXTERNAL:
287                File base = new File(mExternalStorageDir.getPath() + Constants.DEFAULT_DL_SUBDIR);
288                if (!base.isDirectory() && !base.mkdir()) {
289                    // Can't create download directory, e.g. because a file called "download"
290                    // already exists at the root level, or the SD card filesystem is read-only.
291                    throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR,
292                            "unable to create external downloads directory " + base.getPath());
293                }
294                return base;
295            default:
296                throw new IllegalStateException("unexpected value for destination: " + destination);
297        }
298    }
299
300    File getDownloadDataDirectory() {
301        return mDownloadDataDir;
302    }
303
304    /**
305     * Deletes purgeable files from the cache partition. This also deletes
306     * the matching database entries. Files are deleted in LRU order until
307     * the total byte size is greater than targetBytes
308     */
309    private long discardPurgeableFiles(int destination, long targetBytes) {
310        if (true || Constants.LOGV) {
311            Log.i(Constants.TAG, "discardPurgeableFiles: destination = " + destination +
312                    ", targetBytes = " + targetBytes);
313        }
314        String destStr  = (destination == Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) ?
315                String.valueOf(destination) :
316                String.valueOf(Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE);
317        String[] bindArgs = new String[]{destStr};
318        Cursor cursor = mContext.getContentResolver().query(
319                Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
320                null,
321                "( " +
322                Downloads.Impl.COLUMN_STATUS + " = '" + Downloads.Impl.STATUS_SUCCESS + "' AND " +
323                Downloads.Impl.COLUMN_DESTINATION + " = ? )",
324                bindArgs,
325                Downloads.Impl.COLUMN_LAST_MODIFICATION);
326        if (cursor == null) {
327            return 0;
328        }
329        long totalFreed = 0;
330        try {
331            while (cursor.moveToNext() && totalFreed < targetBytes) {
332                File file = new File(cursor.getString(cursor.getColumnIndex(Downloads.Impl._DATA)));
333                if (true || Constants.LOGV) {
334                    Log.i(Constants.TAG, "purging " + file.getAbsolutePath() + " for " +
335                            file.length() + " bytes");
336                }
337                totalFreed += file.length();
338                file.delete();
339                long id = cursor.getLong(cursor.getColumnIndex(Downloads.Impl._ID));
340                mContext.getContentResolver().delete(
341                        ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id),
342                        null, null);
343            }
344        } finally {
345            cursor.close();
346        }
347        if (true || Constants.LOGV) {
348            Log.i(Constants.TAG, "Purged files, freed " + totalFreed + " for " +
349                    targetBytes + " requested");
350        }
351        return totalFreed;
352    }
353
354    /**
355     * Removes files in the systemcache and downloads data dir without corresponding entries in
356     * the downloads database.
357     * This can occur if a delete is done on the database but the file is not removed from the
358     * filesystem (due to sudden death of the process, for example).
359     * This is not a very common occurrence. So, do this only once in a while.
360     */
361    private void removeSpuriousFiles() {
362        if (true || Constants.LOGV) {
363            Log.i(Constants.TAG, "in removeSpuriousFiles");
364        }
365        // get a list of all files in system cache dir and downloads data dir
366        List<File> files = new ArrayList<File>();
367        File[] listOfFiles = mSystemCacheDir.listFiles();
368        if (listOfFiles != null) {
369            files.addAll(Arrays.asList(listOfFiles));
370        }
371        listOfFiles = mDownloadDataDir.listFiles();
372        if (listOfFiles != null) {
373            files.addAll(Arrays.asList(listOfFiles));
374        }
375        if (files.size() == 0) {
376            return;
377        }
378        Cursor cursor = mContext.getContentResolver().query(
379                Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
380                new String[] { Downloads.Impl._DATA }, null, null, null);
381        try {
382            if (cursor != null) {
383                while (cursor.moveToNext()) {
384                    String filename = cursor.getString(0);
385                    if (!TextUtils.isEmpty(filename)) {
386                        if (true || Constants.LOGV) {
387                            Log.i(Constants.TAG, "in removeSpuriousFiles, preserving file " +
388                                    filename);
389                        }
390                        files.remove(new File(filename));
391                    }
392                }
393            }
394        } finally {
395            if (cursor != null) {
396                cursor.close();
397            }
398        }
399        // delete the files not found in the database
400        for (File file : files) {
401            if (file.getName().equals(Constants.KNOWN_SPURIOUS_FILENAME) ||
402                    file.getName().equalsIgnoreCase(Constants.RECOVERY_DIRECTORY)) {
403                continue;
404            }
405            if (true || Constants.LOGV) {
406                Log.i(Constants.TAG, "deleting spurious file " + file.getAbsolutePath());
407            }
408            file.delete();
409        }
410    }
411
412    /**
413     * Drops old rows from the database to prevent it from growing too large
414     * TODO logic in this method needs to be optimized. maintain the number of downloads
415     * in memory - so that this method can limit the amount of data read.
416     */
417    private void trimDatabase() {
418        if (Constants.LOGV) {
419            Log.i(Constants.TAG, "in trimDatabase");
420        }
421        Cursor cursor = null;
422        try {
423            cursor = mContext.getContentResolver().query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
424                    new String[] { Downloads.Impl._ID },
425                    Downloads.Impl.COLUMN_STATUS + " >= '200'", null,
426                    Downloads.Impl.COLUMN_LAST_MODIFICATION);
427            if (cursor == null) {
428                // This isn't good - if we can't do basic queries in our database,
429                // nothing's gonna work
430                Log.e(Constants.TAG, "null cursor in trimDatabase");
431                return;
432            }
433            if (cursor.moveToFirst()) {
434                int numDelete = cursor.getCount() - Constants.MAX_DOWNLOADS;
435                int columnId = cursor.getColumnIndexOrThrow(Downloads.Impl._ID);
436                while (numDelete > 0) {
437                    Uri downloadUri = ContentUris.withAppendedId(
438                            Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, cursor.getLong(columnId));
439                    mContext.getContentResolver().delete(downloadUri, null, null);
440                    if (!cursor.moveToNext()) {
441                        break;
442                    }
443                    numDelete--;
444                }
445            }
446        } catch (SQLiteException e) {
447            // trimming the database raised an exception. alright, ignore the exception
448            // and return silently. trimming database is not exactly a critical operation
449            // and there is no need to propagate the exception.
450            Log.w(Constants.TAG, "trimDatabase failed with exception: " + e.getMessage());
451            return;
452        } finally {
453            if (cursor != null) {
454                cursor.close();
455            }
456        }
457    }
458
459    private synchronized int incrementBytesDownloadedSinceLastCheckOnSpace(long val) {
460        mBytesDownloadedSinceLastCheckOnSpace += val;
461        return mBytesDownloadedSinceLastCheckOnSpace;
462    }
463
464    private synchronized void resetBytesDownloadedSinceLastCheckOnSpace() {
465        mBytesDownloadedSinceLastCheckOnSpace = 0;
466    }
467}
468