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