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