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