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