bug:3286430 set quota on downloads data dir
[android/platform/packages/providers/DownloadProvider.git] / src / com / android / providers / downloads / Helpers.java
1 /*
2  * Copyright (C) 2008 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.Context;
20 import android.content.Intent;
21 import android.content.pm.PackageManager;
22 import android.content.pm.ResolveInfo;
23 import android.drm.mobile1.DrmRawContent;
24 import android.net.Uri;
25 import android.os.Environment;
26 import android.os.SystemClock;
27 import android.provider.Downloads;
28 import android.util.Config;
29 import android.util.Log;
30 import android.webkit.MimeTypeMap;
31
32 import java.io.File;
33 import java.util.Random;
34 import java.util.Set;
35 import java.util.regex.Matcher;
36 import java.util.regex.Pattern;
37
38 /**
39  * Some helper functions for the download manager
40  */
41 public class Helpers {
42     public static Random sRandom = new Random(SystemClock.uptimeMillis());
43
44     /** Regex used to parse content-disposition headers */
45     private static final Pattern CONTENT_DISPOSITION_PATTERN =
46             Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
47
48     private Helpers() {
49     }
50
51     /*
52      * Parse the Content-Disposition HTTP Header. The format of the header
53      * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
54      * This header provides a filename for content that is going to be
55      * downloaded to the file system. We only support the attachment type.
56      */
57     private static String parseContentDisposition(String contentDisposition) {
58         try {
59             Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
60             if (m.find()) {
61                 return m.group(1);
62             }
63         } catch (IllegalStateException ex) {
64              // This function is defined as returning null when it can't parse the header
65         }
66         return null;
67     }
68
69     /**
70      * Creates a filename (where the file should be saved) from info about a download.
71      */
72     static String generateSaveFile(
73             Context context,
74             String url,
75             String hint,
76             String contentDisposition,
77             String contentLocation,
78             String mimeType,
79             int destination,
80             long contentLength,
81             boolean isPublicApi, StorageManager storageManager) throws StopRequestException {
82         checkCanHandleDownload(context, mimeType, destination, isPublicApi);
83         String path;
84         File base = null;
85         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
86             path = Uri.parse(hint).getPath();
87         } else {
88             base = storageManager.locateDestinationDirectory(mimeType, destination,
89                     contentLength);
90             path = chooseFilename(url, hint, contentDisposition, contentLocation,
91                                              destination);
92         }
93         storageManager.verifySpace(destination, path, contentLength);
94         return getFullPath(path, mimeType, destination, base);
95     }
96
97     private static String getFullPath(String filename, String mimeType, int destination,
98         File base) throws StopRequestException {
99         // Split filename between base and extension
100         // Add an extension if filename does not have one
101         String extension = null;
102         int dotIndex = filename.lastIndexOf('.');
103         boolean missingExtension = dotIndex < 0 || dotIndex < filename.lastIndexOf("/");
104         if (missingExtension) {
105             extension = chooseExtensionFromMimeType(mimeType, true);
106         } else {
107             extension = chooseExtensionFromFilename(mimeType, destination, filename, dotIndex);
108             filename = filename.substring(0, dotIndex);
109         }
110
111         boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension);
112
113         if (base != null) {
114             filename = base.getPath() + File.separator + filename;
115         }
116
117         if (Constants.LOGVV) {
118             Log.v(Constants.TAG, "target file: " + filename + extension);
119         }
120         return chooseUniqueFilename(destination, filename, extension, recoveryDir);
121     }
122
123     private static void checkCanHandleDownload(Context context, String mimeType, int destination,
124             boolean isPublicApi) throws StopRequestException {
125         if (isPublicApi) {
126             return;
127         }
128
129         if (destination == Downloads.Impl.DESTINATION_EXTERNAL
130                 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) {
131             if (mimeType == null) {
132                 throw new StopRequestException(Downloads.Impl.STATUS_NOT_ACCEPTABLE,
133                         "external download with no mime type not allowed");
134             }
135             if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) {
136                 // Check to see if we are allowed to download this file. Only files
137                 // that can be handled by the platform can be downloaded.
138                 // special case DRM files, which we should always allow downloading.
139                 Intent intent = new Intent(Intent.ACTION_VIEW);
140
141                 // We can provide data as either content: or file: URIs,
142                 // so allow both.  (I think it would be nice if we just did
143                 // everything as content: URIs)
144                 // Actually, right now the download manager's UId restrictions
145                 // prevent use from using content: so it's got to be file: or
146                 // nothing
147
148                 PackageManager pm = context.getPackageManager();
149                 intent.setDataAndType(Uri.fromParts("file", "", null), mimeType);
150                 ResolveInfo ri = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
151                 //Log.i(Constants.TAG, "*** FILENAME QUERY " + intent + ": " + list);
152
153                 if (ri == null) {
154                     if (Constants.LOGV) {
155                         Log.v(Constants.TAG, "no handler found for type " + mimeType);
156                     }
157                     throw new StopRequestException(Downloads.Impl.STATUS_NOT_ACCEPTABLE,
158                             "no handler found for this download type");
159                 }
160             }
161         }
162     }
163
164     private static String chooseFilename(String url, String hint, String contentDisposition,
165             String contentLocation, int destination) {
166         String filename = null;
167
168         // First, try to use the hint from the application, if there's one
169         if (filename == null && hint != null && !hint.endsWith("/")) {
170             if (Constants.LOGVV) {
171                 Log.v(Constants.TAG, "getting filename from hint");
172             }
173             int index = hint.lastIndexOf('/') + 1;
174             if (index > 0) {
175                 filename = hint.substring(index);
176             } else {
177                 filename = hint;
178             }
179         }
180
181         // If we couldn't do anything with the hint, move toward the content disposition
182         if (filename == null && contentDisposition != null) {
183             filename = parseContentDisposition(contentDisposition);
184             if (filename != null) {
185                 if (Constants.LOGVV) {
186                     Log.v(Constants.TAG, "getting filename from content-disposition");
187                 }
188                 int index = filename.lastIndexOf('/') + 1;
189                 if (index > 0) {
190                     filename = filename.substring(index);
191                 }
192             }
193         }
194
195         // If we still have nothing at this point, try the content location
196         if (filename == null && contentLocation != null) {
197             String decodedContentLocation = Uri.decode(contentLocation);
198             if (decodedContentLocation != null
199                     && !decodedContentLocation.endsWith("/")
200                     && decodedContentLocation.indexOf('?') < 0) {
201                 if (Constants.LOGVV) {
202                     Log.v(Constants.TAG, "getting filename from content-location");
203                 }
204                 int index = decodedContentLocation.lastIndexOf('/') + 1;
205                 if (index > 0) {
206                     filename = decodedContentLocation.substring(index);
207                 } else {
208                     filename = decodedContentLocation;
209                 }
210             }
211         }
212
213         // If all the other http-related approaches failed, use the plain uri
214         if (filename == null) {
215             String decodedUrl = Uri.decode(url);
216             if (decodedUrl != null
217                     && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) {
218                 int index = decodedUrl.lastIndexOf('/') + 1;
219                 if (index > 0) {
220                     if (Constants.LOGVV) {
221                         Log.v(Constants.TAG, "getting filename from uri");
222                     }
223                     filename = decodedUrl.substring(index);
224                 }
225             }
226         }
227
228         // Finally, if couldn't get filename from URI, get a generic filename
229         if (filename == null) {
230             if (Constants.LOGVV) {
231                 Log.v(Constants.TAG, "using default filename");
232             }
233             filename = Constants.DEFAULT_DL_FILENAME;
234         }
235
236         // The VFAT file system is assumed as target for downloads.
237         // Replace invalid characters according to the specifications of VFAT.
238         filename = replaceInvalidVfatCharacters(filename);
239
240         return filename;
241     }
242
243     private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) {
244         String extension = null;
245         if (mimeType != null) {
246             extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
247             if (extension != null) {
248                 if (Constants.LOGVV) {
249                     Log.v(Constants.TAG, "adding extension from type");
250                 }
251                 extension = "." + extension;
252             } else {
253                 if (Constants.LOGVV) {
254                     Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
255                 }
256             }
257         }
258         if (extension == null) {
259             if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) {
260                 if (mimeType.equalsIgnoreCase("text/html")) {
261                     if (Constants.LOGVV) {
262                         Log.v(Constants.TAG, "adding default html extension");
263                     }
264                     extension = Constants.DEFAULT_DL_HTML_EXTENSION;
265                 } else if (useDefaults) {
266                     if (Constants.LOGVV) {
267                         Log.v(Constants.TAG, "adding default text extension");
268                     }
269                     extension = Constants.DEFAULT_DL_TEXT_EXTENSION;
270                 }
271             } else if (useDefaults) {
272                 if (Constants.LOGVV) {
273                     Log.v(Constants.TAG, "adding default binary extension");
274                 }
275                 extension = Constants.DEFAULT_DL_BINARY_EXTENSION;
276             }
277         }
278         return extension;
279     }
280
281     private static String chooseExtensionFromFilename(String mimeType, int destination,
282             String filename, int lastDotIndex) {
283         String extension = null;
284         if (mimeType != null) {
285             // Compare the last segment of the extension against the mime type.
286             // If there's a mismatch, discard the entire extension.
287             String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
288                     filename.substring(lastDotIndex + 1));
289             if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) {
290                 extension = chooseExtensionFromMimeType(mimeType, false);
291                 if (extension != null) {
292                     if (Constants.LOGVV) {
293                         Log.v(Constants.TAG, "substituting extension from type");
294                     }
295                 } else {
296                     if (Constants.LOGVV) {
297                         Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
298                     }
299                 }
300             }
301         }
302         if (extension == null) {
303             if (Constants.LOGVV) {
304                 Log.v(Constants.TAG, "keeping extension");
305             }
306             extension = filename.substring(lastDotIndex);
307         }
308         return extension;
309     }
310
311     private static String chooseUniqueFilename(int destination, String filename,
312             String extension, boolean recoveryDir) throws StopRequestException {
313         String fullFilename = filename + extension;
314         if (!new File(fullFilename).exists()
315                 && (!recoveryDir ||
316                 (destination != Downloads.Impl.DESTINATION_CACHE_PARTITION &&
317                         destination != Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION &&
318                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE &&
319                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING))) {
320             return fullFilename;
321         }
322         filename = filename + Constants.FILENAME_SEQUENCE_SEPARATOR;
323         /*
324         * This number is used to generate partially randomized filenames to avoid
325         * collisions.
326         * It starts at 1.
327         * The next 9 iterations increment it by 1 at a time (up to 10).
328         * The next 9 iterations increment it by 1 to 10 (random) at a time.
329         * The next 9 iterations increment it by 1 to 100 (random) at a time.
330         * ... Up to the point where it increases by 100000000 at a time.
331         * (the maximum value that can be reached is 1000000000)
332         * As soon as a number is reached that generates a filename that doesn't exist,
333         *     that filename is used.
334         * If the filename coming in is [base].[ext], the generated filenames are
335         *     [base]-[sequence].[ext].
336         */
337         int sequence = 1;
338         for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) {
339             for (int iteration = 0; iteration < 9; ++iteration) {
340                 fullFilename = filename + sequence + extension;
341                 if (!new File(fullFilename).exists()) {
342                     return fullFilename;
343                 }
344                 if (Constants.LOGVV) {
345                     Log.v(Constants.TAG, "file with sequence number " + sequence + " exists");
346                 }
347                 sequence += sRandom.nextInt(magnitude) + 1;
348             }
349         }
350         throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR,
351                 "failed to generate an unused filename on internal download storage");
352     }
353
354     /**
355      * Returns whether the network is available
356      */
357     public static boolean isNetworkAvailable(SystemFacade system) {
358         return system.getActiveNetworkType() != null;
359     }
360
361     /**
362      * Checks whether the filename looks legitimate
363      */
364     static boolean isFilenameValid(String filename, File downloadsDataDir) {
365         filename = filename.replaceFirst("/+", "/"); // normalize leading slashes
366         return filename.startsWith(Environment.getDownloadCacheDirectory().toString())
367                 || filename.startsWith(downloadsDataDir.toString())
368                 || filename.startsWith(Environment.getExternalStorageDirectory().toString());
369     }
370
371     /**
372      * Checks whether this looks like a legitimate selection parameter
373      */
374     public static void validateSelection(String selection, Set<String> allowedColumns) {
375         try {
376             if (selection == null || selection.isEmpty()) {
377                 return;
378             }
379             Lexer lexer = new Lexer(selection, allowedColumns);
380             parseExpression(lexer);
381             if (lexer.currentToken() != Lexer.TOKEN_END) {
382                 throw new IllegalArgumentException("syntax error");
383             }
384         } catch (RuntimeException ex) {
385             if (Constants.LOGV) {
386                 Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex);
387             } else if (Config.LOGD) {
388                 Log.d(Constants.TAG, "invalid selection triggered " + ex);
389             }
390             throw ex;
391         }
392
393     }
394
395     // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] *
396     //             | statement [AND_OR expression]*
397     private static void parseExpression(Lexer lexer) {
398         for (;;) {
399             // ( expression )
400             if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) {
401                 lexer.advance();
402                 parseExpression(lexer);
403                 if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) {
404                     throw new IllegalArgumentException("syntax error, unmatched parenthese");
405                 }
406                 lexer.advance();
407             } else {
408                 // statement
409                 parseStatement(lexer);
410             }
411             if (lexer.currentToken() != Lexer.TOKEN_AND_OR) {
412                 break;
413             }
414             lexer.advance();
415         }
416     }
417
418     // statement <- COLUMN COMPARE VALUE
419     //            | COLUMN IS NULL
420     private static void parseStatement(Lexer lexer) {
421         // both possibilities start with COLUMN
422         if (lexer.currentToken() != Lexer.TOKEN_COLUMN) {
423             throw new IllegalArgumentException("syntax error, expected column name");
424         }
425         lexer.advance();
426
427         // statement <- COLUMN COMPARE VALUE
428         if (lexer.currentToken() == Lexer.TOKEN_COMPARE) {
429             lexer.advance();
430             if (lexer.currentToken() != Lexer.TOKEN_VALUE) {
431                 throw new IllegalArgumentException("syntax error, expected quoted string");
432             }
433             lexer.advance();
434             return;
435         }
436
437         // statement <- COLUMN IS NULL
438         if (lexer.currentToken() == Lexer.TOKEN_IS) {
439             lexer.advance();
440             if (lexer.currentToken() != Lexer.TOKEN_NULL) {
441                 throw new IllegalArgumentException("syntax error, expected NULL");
442             }
443             lexer.advance();
444             return;
445         }
446
447         // didn't get anything good after COLUMN
448         throw new IllegalArgumentException("syntax error after column name");
449     }
450
451     /**
452      * A simple lexer that recognizes the words of our restricted subset of SQL where clauses
453      */
454     private static class Lexer {
455         public static final int TOKEN_START = 0;
456         public static final int TOKEN_OPEN_PAREN = 1;
457         public static final int TOKEN_CLOSE_PAREN = 2;
458         public static final int TOKEN_AND_OR = 3;
459         public static final int TOKEN_COLUMN = 4;
460         public static final int TOKEN_COMPARE = 5;
461         public static final int TOKEN_VALUE = 6;
462         public static final int TOKEN_IS = 7;
463         public static final int TOKEN_NULL = 8;
464         public static final int TOKEN_END = 9;
465
466         private final String mSelection;
467         private final Set<String> mAllowedColumns;
468         private int mOffset = 0;
469         private int mCurrentToken = TOKEN_START;
470         private final char[] mChars;
471
472         public Lexer(String selection, Set<String> allowedColumns) {
473             mSelection = selection;
474             mAllowedColumns = allowedColumns;
475             mChars = new char[mSelection.length()];
476             mSelection.getChars(0, mChars.length, mChars, 0);
477             advance();
478         }
479
480         public int currentToken() {
481             return mCurrentToken;
482         }
483
484         public void advance() {
485             char[] chars = mChars;
486
487             // consume whitespace
488             while (mOffset < chars.length && chars[mOffset] == ' ') {
489                 ++mOffset;
490             }
491
492             // end of input
493             if (mOffset == chars.length) {
494                 mCurrentToken = TOKEN_END;
495                 return;
496             }
497
498             // "("
499             if (chars[mOffset] == '(') {
500                 ++mOffset;
501                 mCurrentToken = TOKEN_OPEN_PAREN;
502                 return;
503             }
504
505             // ")"
506             if (chars[mOffset] == ')') {
507                 ++mOffset;
508                 mCurrentToken = TOKEN_CLOSE_PAREN;
509                 return;
510             }
511
512             // "?"
513             if (chars[mOffset] == '?') {
514                 ++mOffset;
515                 mCurrentToken = TOKEN_VALUE;
516                 return;
517             }
518
519             // "=" and "=="
520             if (chars[mOffset] == '=') {
521                 ++mOffset;
522                 mCurrentToken = TOKEN_COMPARE;
523                 if (mOffset < chars.length && chars[mOffset] == '=') {
524                     ++mOffset;
525                 }
526                 return;
527             }
528
529             // ">" and ">="
530             if (chars[mOffset] == '>') {
531                 ++mOffset;
532                 mCurrentToken = TOKEN_COMPARE;
533                 if (mOffset < chars.length && chars[mOffset] == '=') {
534                     ++mOffset;
535                 }
536                 return;
537             }
538
539             // "<", "<=" and "<>"
540             if (chars[mOffset] == '<') {
541                 ++mOffset;
542                 mCurrentToken = TOKEN_COMPARE;
543                 if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) {
544                     ++mOffset;
545                 }
546                 return;
547             }
548
549             // "!="
550             if (chars[mOffset] == '!') {
551                 ++mOffset;
552                 mCurrentToken = TOKEN_COMPARE;
553                 if (mOffset < chars.length && chars[mOffset] == '=') {
554                     ++mOffset;
555                     return;
556                 }
557                 throw new IllegalArgumentException("Unexpected character after !");
558             }
559
560             // columns and keywords
561             // first look for anything that looks like an identifier or a keyword
562             //     and then recognize the individual words.
563             // no attempt is made at discarding sequences of underscores with no alphanumeric
564             //     characters, even though it's not clear that they'd be legal column names.
565             if (isIdentifierStart(chars[mOffset])) {
566                 int startOffset = mOffset;
567                 ++mOffset;
568                 while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) {
569                     ++mOffset;
570                 }
571                 String word = mSelection.substring(startOffset, mOffset);
572                 if (mOffset - startOffset <= 4) {
573                     if (word.equals("IS")) {
574                         mCurrentToken = TOKEN_IS;
575                         return;
576                     }
577                     if (word.equals("OR") || word.equals("AND")) {
578                         mCurrentToken = TOKEN_AND_OR;
579                         return;
580                     }
581                     if (word.equals("NULL")) {
582                         mCurrentToken = TOKEN_NULL;
583                         return;
584                     }
585                 }
586                 if (mAllowedColumns.contains(word)) {
587                     mCurrentToken = TOKEN_COLUMN;
588                     return;
589                 }
590                 throw new IllegalArgumentException("unrecognized column or keyword");
591             }
592
593             // quoted strings
594             if (chars[mOffset] == '\'') {
595                 ++mOffset;
596                 while (mOffset < chars.length) {
597                     if (chars[mOffset] == '\'') {
598                         if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') {
599                             ++mOffset;
600                         } else {
601                             break;
602                         }
603                     }
604                     ++mOffset;
605                 }
606                 if (mOffset == chars.length) {
607                     throw new IllegalArgumentException("unterminated string");
608                 }
609                 ++mOffset;
610                 mCurrentToken = TOKEN_VALUE;
611                 return;
612             }
613
614             // anything we don't recognize
615             throw new IllegalArgumentException("illegal character: " + chars[mOffset]);
616         }
617
618         private static final boolean isIdentifierStart(char c) {
619             return c == '_' ||
620                     (c >= 'A' && c <= 'Z') ||
621                     (c >= 'a' && c <= 'z');
622         }
623
624         private static final boolean isIdentifierChar(char c) {
625             return c == '_' ||
626                     (c >= 'A' && c <= 'Z') ||
627                     (c >= 'a' && c <= 'z') ||
628                     (c >= '0' && c <= '9');
629         }
630     }
631
632     /**
633      * Replace invalid filename characters according to
634      * specifications of the VFAT.
635      * @note Package-private due to testing.
636      */
637     private static String replaceInvalidVfatCharacters(String filename) {
638         final char START_CTRLCODE = 0x00;
639         final char END_CTRLCODE = 0x1f;
640         final char QUOTEDBL = 0x22;
641         final char ASTERISK = 0x2A;
642         final char SLASH = 0x2F;
643         final char COLON = 0x3A;
644         final char LESS = 0x3C;
645         final char GREATER = 0x3E;
646         final char QUESTION = 0x3F;
647         final char BACKSLASH = 0x5C;
648         final char BAR = 0x7C;
649         final char DEL = 0x7F;
650         final char UNDERSCORE = 0x5F;
651
652         StringBuffer sb = new StringBuffer();
653         char ch;
654         boolean isRepetition = false;
655         for (int i = 0; i < filename.length(); i++) {
656             ch = filename.charAt(i);
657             if ((START_CTRLCODE <= ch &&
658                 ch <= END_CTRLCODE) ||
659                 ch == QUOTEDBL ||
660                 ch == ASTERISK ||
661                 ch == SLASH ||
662                 ch == COLON ||
663                 ch == LESS ||
664                 ch == GREATER ||
665                 ch == QUESTION ||
666                 ch == BACKSLASH ||
667                 ch == BAR ||
668                 ch == DEL){
669                 if (!isRepetition) {
670                     sb.append(UNDERSCORE);
671                     isRepetition = true;
672                 }
673             } else {
674                 sb.append(ch);
675                 isRepetition = false;
676             }
677         }
678         return sb.toString();
679     }
680 }