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 17 package com.android.providers.downloads; 18 19 import android.content.ContentUris; 20 import android.content.Context; 21 import android.content.res.Resources; 22 import android.database.Cursor; 23 import android.database.sqlite.SQLiteException; 24 import android.net.Uri; 25 import android.os.Environment; 26 import android.os.StatFs; 27 import android.provider.Downloads; 28 import android.text.TextUtils; 29 import android.util.Log; 30 31 import com.android.internal.R; 32 33 import java.io.File; 34 import java.util.ArrayList; 35 import java.util.Arrays; 36 import 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 */ 43 class 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