2 * Copyright (C) 2008 The Android Open Source Project
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
17 package com.android.providers.downloads;
19 import android.content.ContentUris;
20 import android.content.Context;
21 import android.content.Intent;
22 import android.content.pm.PackageManager;
23 import android.content.pm.ResolveInfo;
24 import android.database.Cursor;
25 import android.drm.mobile1.DrmRawContent;
26 import android.net.ConnectivityManager;
27 import android.net.NetworkInfo;
28 import android.net.Uri;
29 import android.os.Environment;
30 import android.os.StatFs;
31 import android.os.SystemClock;
32 import android.provider.Downloads;
33 import android.telephony.TelephonyManager;
34 import android.util.Config;
35 import android.util.Log;
36 import android.webkit.MimeTypeMap;
39 import java.io.FileNotFoundException;
40 import java.io.FileOutputStream;
41 import java.util.Random;
43 import java.util.regex.Matcher;
44 import java.util.regex.Pattern;
47 * Some helper functions for the download manager
49 public class Helpers {
51 public static Random sRandom = new Random(SystemClock.uptimeMillis());
53 /** Regex used to parse content-disposition headers */
54 private static final Pattern CONTENT_DISPOSITION_PATTERN =
55 Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
61 * Parse the Content-Disposition HTTP Header. The format of the header
62 * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
63 * This header provides a filename for content that is going to be
64 * downloaded to the file system. We only support the attachment type.
66 private static String parseContentDisposition(String contentDisposition) {
68 Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
72 } catch (IllegalStateException ex) {
73 // This function is defined as returning null when it can't parse the header
79 * Creates a filename (where the file should be saved) from a uri.
81 public static DownloadFileInfo generateSaveFile(
85 String contentDisposition,
86 String contentLocation,
89 int contentLength) throws FileNotFoundException {
92 * Don't download files that we won't be able to handle
94 if (destination == Downloads.DESTINATION_EXTERNAL
95 || destination == Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE) {
96 if (mimeType == null) {
98 Log.d(Constants.TAG, "external download with no mime type not allowed");
100 return new DownloadFileInfo(null, null, Downloads.STATUS_NOT_ACCEPTABLE);
102 if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) {
103 // Check to see if we are allowed to download this file. Only files
104 // that can be handled by the platform can be downloaded.
105 // special case DRM files, which we should always allow downloading.
106 Intent intent = new Intent(Intent.ACTION_VIEW);
108 // We can provide data as either content: or file: URIs,
109 // so allow both. (I think it would be nice if we just did
110 // everything as content: URIs)
111 // Actually, right now the download manager's UId restrictions
112 // prevent use from using content: so it's got to be file: or
115 PackageManager pm = context.getPackageManager();
116 intent.setDataAndType(Uri.fromParts("file", "", null), mimeType);
117 ResolveInfo ri = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
118 //Log.i(Constants.TAG, "*** FILENAME QUERY " + intent + ": " + list);
122 Log.d(Constants.TAG, "no handler found for type " + mimeType);
124 return new DownloadFileInfo(null, null, Downloads.STATUS_NOT_ACCEPTABLE);
128 String filename = chooseFilename(
129 url, hint, contentDisposition, contentLocation, destination);
131 // Split filename between base and extension
132 // Add an extension if filename does not have one
133 String extension = null;
134 int dotIndex = filename.indexOf('.');
136 extension = chooseExtensionFromMimeType(mimeType, true);
138 extension = chooseExtensionFromFilename(
139 mimeType, destination, filename, dotIndex);
140 filename = filename.substring(0, dotIndex);
144 * Locate the directory where the file will be saved
149 // DRM messages should be temporarily stored internally and then passed to
150 // the DRM content provider
151 if (destination == Downloads.DESTINATION_CACHE_PARTITION
152 || destination == Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE
153 || destination == Downloads.DESTINATION_CACHE_PARTITION_NOROAMING
154 || DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) {
155 base = Environment.getDownloadCacheDirectory();
156 stat = new StatFs(base.getPath());
159 * Check whether there's enough space on the target filesystem to save the file.
160 * Put a bit of margin (in case creating the file grows the system by a few blocks).
162 int blockSize = stat.getBlockSize();
164 int availableBlocks = stat.getAvailableBlocks();
165 if (blockSize * ((long) availableBlocks - 4) >= contentLength) {
168 if (!discardPurgeableFiles(context,
169 contentLength - blockSize * ((long) availableBlocks - 4))) {
172 "download aborted - not enough free space in internal storage");
174 return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR);
176 stat.restat(base.getPath());
180 if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
181 String root = Environment.getExternalStorageDirectory().getPath();
182 base = new File(root + Constants.DEFAULT_DL_SUBDIR);
183 if (!base.isDirectory() && !base.mkdir()) {
185 Log.d(Constants.TAG, "download aborted - can't create base directory "
188 return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR);
190 stat = new StatFs(base.getPath());
193 Log.d(Constants.TAG, "download aborted - no external storage");
195 return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR);
199 * Check whether there's enough space on the target filesystem to save the file.
200 * Put a bit of margin (in case creating the file grows the system by a few blocks).
202 if (stat.getBlockSize() * ((long) stat.getAvailableBlocks() - 4) < contentLength) {
204 Log.d(Constants.TAG, "download aborted - not enough free space");
206 return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR);
211 boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension);
213 filename = base.getPath() + File.separator + filename;
216 * Generate a unique filename, create the file, return it.
218 if (Constants.LOGVV) {
219 Log.v(Constants.TAG, "target file: " + filename + extension);
222 String fullFilename = chooseUniqueFilename(
223 destination, filename, extension, recoveryDir);
224 if (fullFilename != null) {
225 return new DownloadFileInfo(fullFilename, new FileOutputStream(fullFilename), 0);
227 return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR);
231 private static String chooseFilename(String url, String hint, String contentDisposition,
232 String contentLocation, int destination) {
233 String filename = null;
235 // First, try to use the hint from the application, if there's one
236 if (filename == null && hint != null && !hint.endsWith("/")) {
237 if (Constants.LOGVV) {
238 Log.v(Constants.TAG, "getting filename from hint");
240 int index = hint.lastIndexOf('/') + 1;
242 filename = hint.substring(index);
248 // If we couldn't do anything with the hint, move toward the content disposition
249 if (filename == null && contentDisposition != null) {
250 filename = parseContentDisposition(contentDisposition);
251 if (filename != null) {
252 if (Constants.LOGVV) {
253 Log.v(Constants.TAG, "getting filename from content-disposition");
255 int index = filename.lastIndexOf('/') + 1;
257 filename = filename.substring(index);
262 // If we still have nothing at this point, try the content location
263 if (filename == null && contentLocation != null) {
264 String decodedContentLocation = Uri.decode(contentLocation);
265 if (decodedContentLocation != null
266 && !decodedContentLocation.endsWith("/")
267 && decodedContentLocation.indexOf('?') < 0) {
268 if (Constants.LOGVV) {
269 Log.v(Constants.TAG, "getting filename from content-location");
271 int index = decodedContentLocation.lastIndexOf('/') + 1;
273 filename = decodedContentLocation.substring(index);
275 filename = decodedContentLocation;
280 // If all the other http-related approaches failed, use the plain uri
281 if (filename == null) {
282 String decodedUrl = Uri.decode(url);
283 if (decodedUrl != null
284 && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) {
285 int index = decodedUrl.lastIndexOf('/') + 1;
287 if (Constants.LOGVV) {
288 Log.v(Constants.TAG, "getting filename from uri");
290 filename = decodedUrl.substring(index);
295 // Finally, if couldn't get filename from URI, get a generic filename
296 if (filename == null) {
297 if (Constants.LOGVV) {
298 Log.v(Constants.TAG, "using default filename");
300 filename = Constants.DEFAULT_DL_FILENAME;
303 filename = filename.replaceAll("[^a-zA-Z0-9\\.\\-_]+", "_");
309 private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) {
310 String extension = null;
311 if (mimeType != null) {
312 extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
313 if (extension != null) {
314 if (Constants.LOGVV) {
315 Log.v(Constants.TAG, "adding extension from type");
317 extension = "." + extension;
319 if (Constants.LOGVV) {
320 Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
324 if (extension == null) {
325 if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) {
326 if (mimeType.equalsIgnoreCase("text/html")) {
327 if (Constants.LOGVV) {
328 Log.v(Constants.TAG, "adding default html extension");
330 extension = Constants.DEFAULT_DL_HTML_EXTENSION;
331 } else if (useDefaults) {
332 if (Constants.LOGVV) {
333 Log.v(Constants.TAG, "adding default text extension");
335 extension = Constants.DEFAULT_DL_TEXT_EXTENSION;
337 } else if (useDefaults) {
338 if (Constants.LOGVV) {
339 Log.v(Constants.TAG, "adding default binary extension");
341 extension = Constants.DEFAULT_DL_BINARY_EXTENSION;
347 private static String chooseExtensionFromFilename(String mimeType, int destination,
348 String filename, int dotIndex) {
349 String extension = null;
350 if (mimeType != null) {
351 // Compare the last segment of the extension against the mime type.
352 // If there's a mismatch, discard the entire extension.
353 int lastDotIndex = filename.lastIndexOf('.');
354 String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
355 filename.substring(lastDotIndex + 1));
356 if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) {
357 extension = chooseExtensionFromMimeType(mimeType, false);
358 if (extension != null) {
359 if (Constants.LOGVV) {
360 Log.v(Constants.TAG, "substituting extension from type");
363 if (Constants.LOGVV) {
364 Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
369 if (extension == null) {
370 if (Constants.LOGVV) {
371 Log.v(Constants.TAG, "keeping extension");
373 extension = filename.substring(dotIndex);
378 private static String chooseUniqueFilename(int destination, String filename,
379 String extension, boolean recoveryDir) {
380 String fullFilename = filename + extension;
381 if (!new File(fullFilename).exists()
383 (destination != Downloads.DESTINATION_CACHE_PARTITION &&
384 destination != Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE &&
385 destination != Downloads.DESTINATION_CACHE_PARTITION_NOROAMING))) {
388 filename = filename + Constants.FILENAME_SEQUENCE_SEPARATOR;
390 * This number is used to generate partially randomized filenames to avoid
393 * The next 9 iterations increment it by 1 at a time (up to 10).
394 * The next 9 iterations increment it by 1 to 10 (random) at a time.
395 * The next 9 iterations increment it by 1 to 100 (random) at a time.
396 * ... Up to the point where it increases by 100000000 at a time.
397 * (the maximum value that can be reached is 1000000000)
398 * As soon as a number is reached that generates a filename that doesn't exist,
399 * that filename is used.
400 * If the filename coming in is [base].[ext], the generated filenames are
401 * [base]-[sequence].[ext].
404 for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) {
405 for (int iteration = 0; iteration < 9; ++iteration) {
406 fullFilename = filename + sequence + extension;
407 if (!new File(fullFilename).exists()) {
410 if (Constants.LOGVV) {
411 Log.v(Constants.TAG, "file with sequence number " + sequence + " exists");
413 sequence += sRandom.nextInt(magnitude) + 1;
420 * Deletes purgeable files from the cache partition. This also deletes
421 * the matching database entries. Files are deleted in LRU order until
422 * the total byte size is greater than targetBytes.
424 public static final boolean discardPurgeableFiles(Context context, long targetBytes) {
425 Cursor cursor = context.getContentResolver().query(
426 Downloads.CONTENT_URI,
429 Downloads.COLUMN_STATUS + " = '" + Downloads.STATUS_SUCCESS + "' AND " +
430 Downloads.COLUMN_DESTINATION +
431 " = '" + Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE + "' )",
433 Downloads.COLUMN_LAST_MODIFICATION);
434 if (cursor == null) {
439 cursor.moveToFirst();
440 while (!cursor.isAfterLast() && totalFreed < targetBytes) {
441 File file = new File(cursor.getString(cursor.getColumnIndex(Downloads._DATA)));
442 if (Constants.LOGVV) {
443 Log.v(Constants.TAG, "purging " + file.getAbsolutePath() + " for " +
444 file.length() + " bytes");
446 totalFreed += file.length();
448 long id = cursor.getLong(cursor.getColumnIndex(Downloads._ID));
449 context.getContentResolver().delete(
450 ContentUris.withAppendedId(Downloads.CONTENT_URI, id), null, null);
456 if (Constants.LOGV) {
457 if (totalFreed > 0) {
458 Log.v(Constants.TAG, "Purged files, freed " + totalFreed + " for " +
459 targetBytes + " requested");
462 return totalFreed > 0;
466 * Returns whether the network is available
468 public static boolean isNetworkAvailable(Context context) {
469 ConnectivityManager connectivity =
470 (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
471 if (connectivity == null) {
472 Log.w(Constants.TAG, "couldn't get connectivity manager");
474 NetworkInfo[] info = connectivity.getAllNetworkInfo();
476 for (int i = 0; i < info.length; i++) {
477 if (info[i].getState() == NetworkInfo.State.CONNECTED) {
478 if (Constants.LOGVV) {
479 Log.v(Constants.TAG, "network is available");
486 if (Constants.LOGVV) {
487 Log.v(Constants.TAG, "network is not available");
493 * Returns whether the network is roaming
495 public static boolean isNetworkRoaming(Context context) {
496 ConnectivityManager connectivity =
497 (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
498 if (connectivity == null) {
499 Log.w(Constants.TAG, "couldn't get connectivity manager");
501 NetworkInfo info = connectivity.getActiveNetworkInfo();
502 if (info != null && info.getType() == ConnectivityManager.TYPE_MOBILE) {
503 if (TelephonyManager.getDefault().isNetworkRoaming()) {
504 if (Constants.LOGVV) {
505 Log.v(Constants.TAG, "network is roaming");
509 if (Constants.LOGVV) {
510 Log.v(Constants.TAG, "network is not roaming");
514 if (Constants.LOGVV) {
515 Log.v(Constants.TAG, "not using mobile network");
523 * Checks whether the filename looks legitimate
525 public static boolean isFilenameValid(String filename) {
526 File dir = new File(filename).getParentFile();
527 return dir.equals(Environment.getDownloadCacheDirectory())
528 || dir.equals(new File(Environment.getExternalStorageDirectory()
529 + Constants.DEFAULT_DL_SUBDIR));
533 * Checks whether this looks like a legitimate selection parameter
535 public static void validateSelection(String selection, Set<String> allowedColumns) {
537 if (selection == null) {
540 Lexer lexer = new Lexer(selection, allowedColumns);
541 parseExpression(lexer);
542 if (lexer.currentToken() != Lexer.TOKEN_END) {
543 throw new IllegalArgumentException("syntax error");
545 } catch (RuntimeException ex) {
546 if (Constants.LOGV) {
547 Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex);
548 } else if (Config.LOGD) {
549 Log.d(Constants.TAG, "invalid selection triggered " + ex);
556 // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] *
557 // | statement [AND_OR expression]*
558 private static void parseExpression(Lexer lexer) {
561 if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) {
563 parseExpression(lexer);
564 if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) {
565 throw new IllegalArgumentException("syntax error, unmatched parenthese");
570 parseStatement(lexer);
572 if (lexer.currentToken() != Lexer.TOKEN_AND_OR) {
579 // statement <- COLUMN COMPARE VALUE
581 private static void parseStatement(Lexer lexer) {
582 // both possibilities start with COLUMN
583 if (lexer.currentToken() != Lexer.TOKEN_COLUMN) {
584 throw new IllegalArgumentException("syntax error, expected column name");
588 // statement <- COLUMN COMPARE VALUE
589 if (lexer.currentToken() == Lexer.TOKEN_COMPARE) {
591 if (lexer.currentToken() != Lexer.TOKEN_VALUE) {
592 throw new IllegalArgumentException("syntax error, expected quoted string");
598 // statement <- COLUMN IS NULL
599 if (lexer.currentToken() == Lexer.TOKEN_IS) {
601 if (lexer.currentToken() != Lexer.TOKEN_NULL) {
602 throw new IllegalArgumentException("syntax error, expected NULL");
608 // didn't get anything good after COLUMN
609 throw new IllegalArgumentException("syntax error after column name");
613 * A simple lexer that recognizes the words of our restricted subset of SQL where clauses
615 private static class Lexer {
616 public static final int TOKEN_START = 0;
617 public static final int TOKEN_OPEN_PAREN = 1;
618 public static final int TOKEN_CLOSE_PAREN = 2;
619 public static final int TOKEN_AND_OR = 3;
620 public static final int TOKEN_COLUMN = 4;
621 public static final int TOKEN_COMPARE = 5;
622 public static final int TOKEN_VALUE = 6;
623 public static final int TOKEN_IS = 7;
624 public static final int TOKEN_NULL = 8;
625 public static final int TOKEN_END = 9;
627 private final String mSelection;
628 private final Set<String> mAllowedColumns;
629 private int mOffset = 0;
630 private int mCurrentToken = TOKEN_START;
631 private final char[] mChars;
633 public Lexer(String selection, Set<String> allowedColumns) {
634 mSelection = selection;
635 mAllowedColumns = allowedColumns;
636 mChars = new char[mSelection.length()];
637 mSelection.getChars(0, mChars.length, mChars, 0);
641 public int currentToken() {
642 return mCurrentToken;
645 public void advance() {
646 char[] chars = mChars;
648 // consume whitespace
649 while (mOffset < chars.length && chars[mOffset] == ' ') {
654 if (mOffset == chars.length) {
655 mCurrentToken = TOKEN_END;
660 if (chars[mOffset] == '(') {
662 mCurrentToken = TOKEN_OPEN_PAREN;
667 if (chars[mOffset] == ')') {
669 mCurrentToken = TOKEN_CLOSE_PAREN;
674 if (chars[mOffset] == '?') {
676 mCurrentToken = TOKEN_VALUE;
681 if (chars[mOffset] == '=') {
683 mCurrentToken = TOKEN_COMPARE;
684 if (mOffset < chars.length && chars[mOffset] == '=') {
691 if (chars[mOffset] == '>') {
693 mCurrentToken = TOKEN_COMPARE;
694 if (mOffset < chars.length && chars[mOffset] == '=') {
700 // "<", "<=" and "<>"
701 if (chars[mOffset] == '<') {
703 mCurrentToken = TOKEN_COMPARE;
704 if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) {
711 if (chars[mOffset] == '!') {
713 mCurrentToken = TOKEN_COMPARE;
714 if (mOffset < chars.length && chars[mOffset] == '=') {
718 throw new IllegalArgumentException("Unexpected character after !");
721 // columns and keywords
722 // first look for anything that looks like an identifier or a keyword
723 // and then recognize the individual words.
724 // no attempt is made at discarding sequences of underscores with no alphanumeric
725 // characters, even though it's not clear that they'd be legal column names.
726 if (isIdentifierStart(chars[mOffset])) {
727 int startOffset = mOffset;
729 while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) {
732 String word = mSelection.substring(startOffset, mOffset);
733 if (mOffset - startOffset <= 4) {
734 if (word.equals("IS")) {
735 mCurrentToken = TOKEN_IS;
738 if (word.equals("OR") || word.equals("AND")) {
739 mCurrentToken = TOKEN_AND_OR;
742 if (word.equals("NULL")) {
743 mCurrentToken = TOKEN_NULL;
747 if (mAllowedColumns.contains(word)) {
748 mCurrentToken = TOKEN_COLUMN;
751 throw new IllegalArgumentException("unrecognized column or keyword");
755 if (chars[mOffset] == '\'') {
757 while (mOffset < chars.length) {
758 if (chars[mOffset] == '\'') {
759 if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') {
767 if (mOffset == chars.length) {
768 throw new IllegalArgumentException("unterminated string");
771 mCurrentToken = TOKEN_VALUE;
775 // anything we don't recognize
776 throw new IllegalArgumentException("illegal character");
779 private static final boolean isIdentifierStart(char c) {
781 (c >= 'A' && c <= 'Z') ||
782 (c >= 'a' && c <= 'z');
785 private static final boolean isIdentifierChar(char c) {
787 (c >= 'A' && c <= 'Z') ||
788 (c >= 'a' && c <= 'z') ||
789 (c >= '0' && c <= '9');