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