Merge 216736d2 from gingerbread
[android/platform/packages/providers/DownloadProvider.git] / src / com / android / providers / downloads / Helpers.java
1 /*
2  * Copyright (C) 2008 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package com.android.providers.downloads;
18
19 import android.content.ContentResolver;
20 import android.content.ContentUris;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.pm.PackageManager;
24 import android.content.pm.ResolveInfo;
25 import android.database.Cursor;
26 import android.drm.mobile1.DrmRawContent;
27 import android.net.Uri;
28 import android.os.Environment;
29 import android.os.StatFs;
30 import android.os.SystemClock;
31 import android.provider.Downloads;
32 import android.util.Config;
33 import android.util.Log;
34 import android.webkit.MimeTypeMap;
35
36 import java.io.File;
37 import java.util.Random;
38 import java.util.Set;
39 import java.util.regex.Matcher;
40 import java.util.regex.Pattern;
41
42 /**
43  * Some helper functions for the download manager
44  */
45 public class Helpers {
46
47     public static Random sRandom = new Random(SystemClock.uptimeMillis());
48
49     /** Regex used to parse content-disposition headers */
50     private static final Pattern CONTENT_DISPOSITION_PATTERN =
51             Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
52
53     private Helpers() {
54     }
55
56     /*
57      * Parse the Content-Disposition HTTP Header. The format of the header
58      * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
59      * This header provides a filename for content that is going to be
60      * downloaded to the file system. We only support the attachment type.
61      */
62     private static String parseContentDisposition(String contentDisposition) {
63         try {
64             Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
65             if (m.find()) {
66                 return m.group(1);
67             }
68         } catch (IllegalStateException ex) {
69              // This function is defined as returning null when it can't parse the header
70         }
71         return null;
72     }
73
74     /**
75      * Exception thrown from methods called by generateSaveFile() for any fatal error.
76      */
77     public static class GenerateSaveFileError extends Exception {
78         int mStatus;
79         String mMessage;
80
81         public GenerateSaveFileError(int status, String message) {
82             mStatus = status;
83             mMessage = message;
84         }
85     }
86
87     /**
88      * Creates a filename (where the file should be saved) from info about a download.
89      */
90     public static String generateSaveFile(
91             Context context,
92             String url,
93             String hint,
94             String contentDisposition,
95             String contentLocation,
96             String mimeType,
97             int destination,
98             long contentLength,
99             boolean isPublicApi) throws GenerateSaveFileError {
100         checkCanHandleDownload(context, mimeType, destination, isPublicApi);
101         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
102             return getPathForFileUri(hint, contentLength);
103         } else {
104             return chooseFullPath(context, url, hint, contentDisposition, contentLocation, mimeType,
105                     destination, contentLength);
106         }
107     }
108
109     private static String getPathForFileUri(String hint, long contentLength)
110             throws GenerateSaveFileError {
111         if (!isExternalMediaMounted()) {
112             throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR,
113                     "external media not mounted");
114         }
115         String path = Uri.parse(hint).getPath();
116         if (new File(path).exists()) {
117             Log.d(Constants.TAG, "File already exists: " + path);
118             throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ALREADY_EXISTS_ERROR,
119                     "requested destination file already exists");
120         }
121         if (getAvailableBytes(getFilesystemRoot(path)) < contentLength) {
122             throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
123                     "insufficient space on external storage");
124         }
125
126         return path;
127     }
128
129     /**
130      * @return the root of the filesystem containing the given path
131      */
132     public static File getFilesystemRoot(String path) {
133         File cache = Environment.getDownloadCacheDirectory();
134         if (path.startsWith(cache.getPath())) {
135             return cache;
136         }
137         File external = Environment.getExternalStorageDirectory();
138         if (path.startsWith(external.getPath())) {
139             return external;
140         }
141         throw new IllegalArgumentException("Cannot determine filesystem root for " + path);
142     }
143
144     private static String chooseFullPath(Context context, String url, String hint,
145                                          String contentDisposition, String contentLocation,
146                                          String mimeType, int destination, long contentLength)
147             throws GenerateSaveFileError {
148         File base = locateDestinationDirectory(context, mimeType, destination, contentLength);
149         String filename = chooseFilename(url, hint, contentDisposition, contentLocation,
150                                          destination);
151
152         // Split filename between base and extension
153         // Add an extension if filename does not have one
154         String extension = null;
155         int dotIndex = filename.indexOf('.');
156         if (dotIndex < 0) {
157             extension = chooseExtensionFromMimeType(mimeType, true);
158         } else {
159             extension = chooseExtensionFromFilename(mimeType, destination, filename, dotIndex);
160             filename = filename.substring(0, dotIndex);
161         }
162
163         boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension);
164
165         filename = base.getPath() + File.separator + filename;
166
167         if (Constants.LOGVV) {
168             Log.v(Constants.TAG, "target file: " + filename + extension);
169         }
170
171         return chooseUniqueFilename(destination, filename, extension, recoveryDir);
172     }
173
174     private static void checkCanHandleDownload(Context context, String mimeType, int destination,
175             boolean isPublicApi) throws GenerateSaveFileError {
176         if (isPublicApi) {
177             return;
178         }
179
180         if (destination == Downloads.Impl.DESTINATION_EXTERNAL
181                 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) {
182             if (mimeType == null) {
183                 throw new GenerateSaveFileError(Downloads.Impl.STATUS_NOT_ACCEPTABLE,
184                         "external download with no mime type not allowed");
185             }
186             if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) {
187                 // Check to see if we are allowed to download this file. Only files
188                 // that can be handled by the platform can be downloaded.
189                 // special case DRM files, which we should always allow downloading.
190                 Intent intent = new Intent(Intent.ACTION_VIEW);
191
192                 // We can provide data as either content: or file: URIs,
193                 // so allow both.  (I think it would be nice if we just did
194                 // everything as content: URIs)
195                 // Actually, right now the download manager's UId restrictions
196                 // prevent use from using content: so it's got to be file: or
197                 // nothing
198
199                 PackageManager pm = context.getPackageManager();
200                 intent.setDataAndType(Uri.fromParts("file", "", null), mimeType);
201                 ResolveInfo ri = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
202                 //Log.i(Constants.TAG, "*** FILENAME QUERY " + intent + ": " + list);
203
204                 if (ri == null) {
205                     if (Constants.LOGV) {
206                         Log.v(Constants.TAG, "no handler found for type " + mimeType);
207                     }
208                     throw new GenerateSaveFileError(Downloads.Impl.STATUS_NOT_ACCEPTABLE,
209                             "no handler found for this download type");
210                 }
211             }
212         }
213     }
214
215     private static File locateDestinationDirectory(Context context, String mimeType,
216                                                    int destination, long contentLength)
217             throws GenerateSaveFileError {
218         // DRM messages should be temporarily stored internally and then passed to
219         // the DRM content provider
220         if (destination == Downloads.Impl.DESTINATION_CACHE_PARTITION
221                 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE
222                 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING
223                 || DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) {
224             return getCacheDestination(context, contentLength);
225         }
226
227         return getExternalDestination(contentLength);
228     }
229
230     private static File getExternalDestination(long contentLength) throws GenerateSaveFileError {
231         if (!isExternalMediaMounted()) {
232             throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR,
233                     "external media not mounted");
234         }
235
236         File root = Environment.getExternalStorageDirectory();
237         if (getAvailableBytes(root) < contentLength) {
238             // Insufficient space.
239             Log.d(Constants.TAG, "download aborted - not enough free space");
240             throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
241                     "insufficient space on external media");
242         }
243
244         File base = new File(root.getPath() + Constants.DEFAULT_DL_SUBDIR);
245         if (!base.isDirectory() && !base.mkdir()) {
246             // Can't create download directory, e.g. because a file called "download"
247             // already exists at the root level, or the SD card filesystem is read-only.
248             throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR,
249                     "unable to create external downloads directory " + base.getPath());
250         }
251         return base;
252     }
253
254     public static boolean isExternalMediaMounted() {
255         if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
256             // No SD card found.
257             Log.d(Constants.TAG, "no external storage");
258             return false;
259         }
260         return true;
261     }
262
263     private static File getCacheDestination(Context context, long contentLength)
264             throws GenerateSaveFileError {
265         File base;
266         base = Environment.getDownloadCacheDirectory();
267         long bytesAvailable = getAvailableBytes(base);
268         while (bytesAvailable < contentLength) {
269             // Insufficient space; try discarding purgeable files.
270             if (!discardPurgeableFiles(context, contentLength - bytesAvailable)) {
271                 // No files to purge, give up.
272                 throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
273                         "not enough free space in internal download storage, unable to free any "
274                         + "more");
275             }
276             bytesAvailable = getAvailableBytes(base);
277         }
278         return base;
279     }
280
281     /**
282      * @return the number of bytes available on the filesystem rooted at the given File
283      */
284     public static long getAvailableBytes(File root) {
285         StatFs stat = new StatFs(root.getPath());
286         // put a bit of margin (in case creating the file grows the system by a few blocks)
287         long availableBlocks = (long) stat.getAvailableBlocks() - 4;
288         return stat.getBlockSize() * availableBlocks;
289     }
290
291     private static String chooseFilename(String url, String hint, String contentDisposition,
292             String contentLocation, int destination) {
293         String filename = null;
294
295         // First, try to use the hint from the application, if there's one
296         if (filename == null && hint != null && !hint.endsWith("/")) {
297             if (Constants.LOGVV) {
298                 Log.v(Constants.TAG, "getting filename from hint");
299             }
300             int index = hint.lastIndexOf('/') + 1;
301             if (index > 0) {
302                 filename = hint.substring(index);
303             } else {
304                 filename = hint;
305             }
306         }
307
308         // If we couldn't do anything with the hint, move toward the content disposition
309         if (filename == null && contentDisposition != null) {
310             filename = parseContentDisposition(contentDisposition);
311             if (filename != null) {
312                 if (Constants.LOGVV) {
313                     Log.v(Constants.TAG, "getting filename from content-disposition");
314                 }
315                 int index = filename.lastIndexOf('/') + 1;
316                 if (index > 0) {
317                     filename = filename.substring(index);
318                 }
319             }
320         }
321
322         // If we still have nothing at this point, try the content location
323         if (filename == null && contentLocation != null) {
324             String decodedContentLocation = Uri.decode(contentLocation);
325             if (decodedContentLocation != null
326                     && !decodedContentLocation.endsWith("/")
327                     && decodedContentLocation.indexOf('?') < 0) {
328                 if (Constants.LOGVV) {
329                     Log.v(Constants.TAG, "getting filename from content-location");
330                 }
331                 int index = decodedContentLocation.lastIndexOf('/') + 1;
332                 if (index > 0) {
333                     filename = decodedContentLocation.substring(index);
334                 } else {
335                     filename = decodedContentLocation;
336                 }
337             }
338         }
339
340         // If all the other http-related approaches failed, use the plain uri
341         if (filename == null) {
342             String decodedUrl = Uri.decode(url);
343             if (decodedUrl != null
344                     && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) {
345                 int index = decodedUrl.lastIndexOf('/') + 1;
346                 if (index > 0) {
347                     if (Constants.LOGVV) {
348                         Log.v(Constants.TAG, "getting filename from uri");
349                     }
350                     filename = decodedUrl.substring(index);
351                 }
352             }
353         }
354
355         // Finally, if couldn't get filename from URI, get a generic filename
356         if (filename == null) {
357             if (Constants.LOGVV) {
358                 Log.v(Constants.TAG, "using default filename");
359             }
360             filename = Constants.DEFAULT_DL_FILENAME;
361         }
362
363         // The VFAT file system is assumed as target for downloads.
364         // Replace invalid characters according to the specifications of VFAT.
365         filename = replaceInvalidVfatCharacters(filename);
366
367         return filename;
368     }
369
370     private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) {
371         String extension = null;
372         if (mimeType != null) {
373             extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
374             if (extension != null) {
375                 if (Constants.LOGVV) {
376                     Log.v(Constants.TAG, "adding extension from type");
377                 }
378                 extension = "." + extension;
379             } else {
380                 if (Constants.LOGVV) {
381                     Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
382                 }
383             }
384         }
385         if (extension == null) {
386             if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) {
387                 if (mimeType.equalsIgnoreCase("text/html")) {
388                     if (Constants.LOGVV) {
389                         Log.v(Constants.TAG, "adding default html extension");
390                     }
391                     extension = Constants.DEFAULT_DL_HTML_EXTENSION;
392                 } else if (useDefaults) {
393                     if (Constants.LOGVV) {
394                         Log.v(Constants.TAG, "adding default text extension");
395                     }
396                     extension = Constants.DEFAULT_DL_TEXT_EXTENSION;
397                 }
398             } else if (useDefaults) {
399                 if (Constants.LOGVV) {
400                     Log.v(Constants.TAG, "adding default binary extension");
401                 }
402                 extension = Constants.DEFAULT_DL_BINARY_EXTENSION;
403             }
404         }
405         return extension;
406     }
407
408     private static String chooseExtensionFromFilename(String mimeType, int destination,
409             String filename, int dotIndex) {
410         String extension = null;
411         if (mimeType != null) {
412             // Compare the last segment of the extension against the mime type.
413             // If there's a mismatch, discard the entire extension.
414             int lastDotIndex = filename.lastIndexOf('.');
415             String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
416                     filename.substring(lastDotIndex + 1));
417             if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) {
418                 extension = chooseExtensionFromMimeType(mimeType, false);
419                 if (extension != null) {
420                     if (Constants.LOGVV) {
421                         Log.v(Constants.TAG, "substituting extension from type");
422                     }
423                 } else {
424                     if (Constants.LOGVV) {
425                         Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
426                     }
427                 }
428             }
429         }
430         if (extension == null) {
431             if (Constants.LOGVV) {
432                 Log.v(Constants.TAG, "keeping extension");
433             }
434             extension = filename.substring(dotIndex);
435         }
436         return extension;
437     }
438
439     private static String chooseUniqueFilename(int destination, String filename,
440             String extension, boolean recoveryDir) throws GenerateSaveFileError {
441         String fullFilename = filename + extension;
442         if (!new File(fullFilename).exists()
443                 && (!recoveryDir ||
444                 (destination != Downloads.Impl.DESTINATION_CACHE_PARTITION &&
445                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE &&
446                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING))) {
447             return fullFilename;
448         }
449         filename = filename + Constants.FILENAME_SEQUENCE_SEPARATOR;
450         /*
451         * This number is used to generate partially randomized filenames to avoid
452         * collisions.
453         * It starts at 1.
454         * The next 9 iterations increment it by 1 at a time (up to 10).
455         * The next 9 iterations increment it by 1 to 10 (random) at a time.
456         * The next 9 iterations increment it by 1 to 100 (random) at a time.
457         * ... Up to the point where it increases by 100000000 at a time.
458         * (the maximum value that can be reached is 1000000000)
459         * As soon as a number is reached that generates a filename that doesn't exist,
460         *     that filename is used.
461         * If the filename coming in is [base].[ext], the generated filenames are
462         *     [base]-[sequence].[ext].
463         */
464         int sequence = 1;
465         for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) {
466             for (int iteration = 0; iteration < 9; ++iteration) {
467                 fullFilename = filename + sequence + extension;
468                 if (!new File(fullFilename).exists()) {
469                     return fullFilename;
470                 }
471                 if (Constants.LOGVV) {
472                     Log.v(Constants.TAG, "file with sequence number " + sequence + " exists");
473                 }
474                 sequence += sRandom.nextInt(magnitude) + 1;
475             }
476         }
477         throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR,
478                 "failed to generate an unused filename on internal download storage");
479     }
480
481     /**
482      * Deletes purgeable files from the cache partition. This also deletes
483      * the matching database entries. Files are deleted in LRU order until
484      * the total byte size is greater than targetBytes.
485      */
486     public static final boolean discardPurgeableFiles(Context context, long targetBytes) {
487         Cursor cursor = context.getContentResolver().query(
488                 Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
489                 null,
490                 "( " +
491                 Downloads.Impl.COLUMN_STATUS + " = '" + Downloads.Impl.STATUS_SUCCESS + "' AND " +
492                 Downloads.Impl.COLUMN_DESTINATION +
493                         " = '" + Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE + "' )",
494                 null,
495                 Downloads.Impl.COLUMN_LAST_MODIFICATION);
496         if (cursor == null) {
497             return false;
498         }
499         long totalFreed = 0;
500         try {
501             cursor.moveToFirst();
502             while (!cursor.isAfterLast() && totalFreed < targetBytes) {
503                 File file = new File(cursor.getString(cursor.getColumnIndex(Downloads.Impl._DATA)));
504                 if (Constants.LOGVV) {
505                     Log.v(Constants.TAG, "purging " + file.getAbsolutePath() + " for " +
506                             file.length() + " bytes");
507                 }
508                 totalFreed += file.length();
509                 file.delete();
510                 long id = cursor.getLong(cursor.getColumnIndex(Downloads.Impl._ID));
511                 context.getContentResolver().delete(
512                         ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id),
513                         null, null);
514                 cursor.moveToNext();
515             }
516         } finally {
517             cursor.close();
518         }
519         if (Constants.LOGV) {
520             if (totalFreed > 0) {
521                 Log.v(Constants.TAG, "Purged files, freed " + totalFreed + " for " +
522                         targetBytes + " requested");
523             }
524         }
525         return totalFreed > 0;
526     }
527
528     /**
529      * Returns whether the network is available
530      */
531     public static boolean isNetworkAvailable(SystemFacade system) {
532         return system.getActiveNetworkType() != null;
533     }
534
535     /**
536      * Checks whether the filename looks legitimate
537      */
538     public static boolean isFilenameValid(String filename) {
539         filename = filename.replaceFirst("/+", "/"); // normalize leading slashes
540         return filename.startsWith(Environment.getDownloadCacheDirectory().toString())
541                 || filename.startsWith(Environment.getExternalStorageDirectory().toString());
542     }
543
544     /**
545      * Checks whether this looks like a legitimate selection parameter
546      */
547     public static void validateSelection(String selection, Set<String> allowedColumns) {
548         try {
549             if (selection == null || selection.isEmpty()) {
550                 return;
551             }
552             Lexer lexer = new Lexer(selection, allowedColumns);
553             parseExpression(lexer);
554             if (lexer.currentToken() != Lexer.TOKEN_END) {
555                 throw new IllegalArgumentException("syntax error");
556             }
557         } catch (RuntimeException ex) {
558             if (Constants.LOGV) {
559                 Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex);
560             } else if (Config.LOGD) {
561                 Log.d(Constants.TAG, "invalid selection triggered " + ex);
562             }
563             throw ex;
564         }
565
566     }
567
568     // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] *
569     //             | statement [AND_OR expression]*
570     private static void parseExpression(Lexer lexer) {
571         for (;;) {
572             // ( expression )
573             if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) {
574                 lexer.advance();
575                 parseExpression(lexer);
576                 if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) {
577                     throw new IllegalArgumentException("syntax error, unmatched parenthese");
578                 }
579                 lexer.advance();
580             } else {
581                 // statement
582                 parseStatement(lexer);
583             }
584             if (lexer.currentToken() != Lexer.TOKEN_AND_OR) {
585                 break;
586             }
587             lexer.advance();
588         }
589     }
590
591     // statement <- COLUMN COMPARE VALUE
592     //            | COLUMN IS NULL
593     private static void parseStatement(Lexer lexer) {
594         // both possibilities start with COLUMN
595         if (lexer.currentToken() != Lexer.TOKEN_COLUMN) {
596             throw new IllegalArgumentException("syntax error, expected column name");
597         }
598         lexer.advance();
599
600         // statement <- COLUMN COMPARE VALUE
601         if (lexer.currentToken() == Lexer.TOKEN_COMPARE) {
602             lexer.advance();
603             if (lexer.currentToken() != Lexer.TOKEN_VALUE) {
604                 throw new IllegalArgumentException("syntax error, expected quoted string");
605             }
606             lexer.advance();
607             return;
608         }
609
610         // statement <- COLUMN IS NULL
611         if (lexer.currentToken() == Lexer.TOKEN_IS) {
612             lexer.advance();
613             if (lexer.currentToken() != Lexer.TOKEN_NULL) {
614                 throw new IllegalArgumentException("syntax error, expected NULL");
615             }
616             lexer.advance();
617             return;
618         }
619
620         // didn't get anything good after COLUMN
621         throw new IllegalArgumentException("syntax error after column name");
622     }
623
624     /**
625      * A simple lexer that recognizes the words of our restricted subset of SQL where clauses
626      */
627     private static class Lexer {
628         public static final int TOKEN_START = 0;
629         public static final int TOKEN_OPEN_PAREN = 1;
630         public static final int TOKEN_CLOSE_PAREN = 2;
631         public static final int TOKEN_AND_OR = 3;
632         public static final int TOKEN_COLUMN = 4;
633         public static final int TOKEN_COMPARE = 5;
634         public static final int TOKEN_VALUE = 6;
635         public static final int TOKEN_IS = 7;
636         public static final int TOKEN_NULL = 8;
637         public static final int TOKEN_END = 9;
638
639         private final String mSelection;
640         private final Set<String> mAllowedColumns;
641         private int mOffset = 0;
642         private int mCurrentToken = TOKEN_START;
643         private final char[] mChars;
644
645         public Lexer(String selection, Set<String> allowedColumns) {
646             mSelection = selection;
647             mAllowedColumns = allowedColumns;
648             mChars = new char[mSelection.length()];
649             mSelection.getChars(0, mChars.length, mChars, 0);
650             advance();
651         }
652
653         public int currentToken() {
654             return mCurrentToken;
655         }
656
657         public void advance() {
658             char[] chars = mChars;
659
660             // consume whitespace
661             while (mOffset < chars.length && chars[mOffset] == ' ') {
662                 ++mOffset;
663             }
664
665             // end of input
666             if (mOffset == chars.length) {
667                 mCurrentToken = TOKEN_END;
668                 return;
669             }
670
671             // "("
672             if (chars[mOffset] == '(') {
673                 ++mOffset;
674                 mCurrentToken = TOKEN_OPEN_PAREN;
675                 return;
676             }
677
678             // ")"
679             if (chars[mOffset] == ')') {
680                 ++mOffset;
681                 mCurrentToken = TOKEN_CLOSE_PAREN;
682                 return;
683             }
684
685             // "?"
686             if (chars[mOffset] == '?') {
687                 ++mOffset;
688                 mCurrentToken = TOKEN_VALUE;
689                 return;
690             }
691
692             // "=" and "=="
693             if (chars[mOffset] == '=') {
694                 ++mOffset;
695                 mCurrentToken = TOKEN_COMPARE;
696                 if (mOffset < chars.length && chars[mOffset] == '=') {
697                     ++mOffset;
698                 }
699                 return;
700             }
701
702             // ">" and ">="
703             if (chars[mOffset] == '>') {
704                 ++mOffset;
705                 mCurrentToken = TOKEN_COMPARE;
706                 if (mOffset < chars.length && chars[mOffset] == '=') {
707                     ++mOffset;
708                 }
709                 return;
710             }
711
712             // "<", "<=" and "<>"
713             if (chars[mOffset] == '<') {
714                 ++mOffset;
715                 mCurrentToken = TOKEN_COMPARE;
716                 if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) {
717                     ++mOffset;
718                 }
719                 return;
720             }
721
722             // "!="
723             if (chars[mOffset] == '!') {
724                 ++mOffset;
725                 mCurrentToken = TOKEN_COMPARE;
726                 if (mOffset < chars.length && chars[mOffset] == '=') {
727                     ++mOffset;
728                     return;
729                 }
730                 throw new IllegalArgumentException("Unexpected character after !");
731             }
732
733             // columns and keywords
734             // first look for anything that looks like an identifier or a keyword
735             //     and then recognize the individual words.
736             // no attempt is made at discarding sequences of underscores with no alphanumeric
737             //     characters, even though it's not clear that they'd be legal column names.
738             if (isIdentifierStart(chars[mOffset])) {
739                 int startOffset = mOffset;
740                 ++mOffset;
741                 while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) {
742                     ++mOffset;
743                 }
744                 String word = mSelection.substring(startOffset, mOffset);
745                 if (mOffset - startOffset <= 4) {
746                     if (word.equals("IS")) {
747                         mCurrentToken = TOKEN_IS;
748                         return;
749                     }
750                     if (word.equals("OR") || word.equals("AND")) {
751                         mCurrentToken = TOKEN_AND_OR;
752                         return;
753                     }
754                     if (word.equals("NULL")) {
755                         mCurrentToken = TOKEN_NULL;
756                         return;
757                     }
758                 }
759                 if (mAllowedColumns.contains(word)) {
760                     mCurrentToken = TOKEN_COLUMN;
761                     return;
762                 }
763                 throw new IllegalArgumentException("unrecognized column or keyword");
764             }
765
766             // quoted strings
767             if (chars[mOffset] == '\'') {
768                 ++mOffset;
769                 while (mOffset < chars.length) {
770                     if (chars[mOffset] == '\'') {
771                         if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') {
772                             ++mOffset;
773                         } else {
774                             break;
775                         }
776                     }
777                     ++mOffset;
778                 }
779                 if (mOffset == chars.length) {
780                     throw new IllegalArgumentException("unterminated string");
781                 }
782                 ++mOffset;
783                 mCurrentToken = TOKEN_VALUE;
784                 return;
785             }
786
787             // anything we don't recognize
788             throw new IllegalArgumentException("illegal character: " + chars[mOffset]);
789         }
790
791         private static final boolean isIdentifierStart(char c) {
792             return c == '_' ||
793                     (c >= 'A' && c <= 'Z') ||
794                     (c >= 'a' && c <= 'z');
795         }
796
797         private static final boolean isIdentifierChar(char c) {
798             return c == '_' ||
799                     (c >= 'A' && c <= 'Z') ||
800                     (c >= 'a' && c <= 'z') ||
801                     (c >= '0' && c <= '9');
802         }
803     }
804
805     /**
806      * Replace invalid filename characters according to
807      * specifications of the VFAT.
808      * @note Package-private due to testing.
809      */
810     private static String replaceInvalidVfatCharacters(String filename) {
811         final char START_CTRLCODE = 0x00;
812         final char END_CTRLCODE = 0x1f;
813         final char QUOTEDBL = 0x22;
814         final char ASTERISK = 0x2A;
815         final char SLASH = 0x2F;
816         final char COLON = 0x3A;
817         final char LESS = 0x3C;
818         final char GREATER = 0x3E;
819         final char QUESTION = 0x3F;
820         final char BACKSLASH = 0x5C;
821         final char BAR = 0x7C;
822         final char DEL = 0x7F;
823         final char UNDERSCORE = 0x5F;
824
825         StringBuffer sb = new StringBuffer();
826         char ch;
827         boolean isRepetition = false;
828         for (int i = 0; i < filename.length(); i++) {
829             ch = filename.charAt(i);
830             if ((START_CTRLCODE <= ch &&
831                 ch <= END_CTRLCODE) ||
832                 ch == QUOTEDBL ||
833                 ch == ASTERISK ||
834                 ch == SLASH ||
835                 ch == COLON ||
836                 ch == LESS ||
837                 ch == GREATER ||
838                 ch == QUESTION ||
839                 ch == BACKSLASH ||
840                 ch == BAR ||
841                 ch == DEL){
842                 if (!isRepetition) {
843                     sb.append(UNDERSCORE);
844                     isRepetition = true;
845                 }
846             } else {
847                 sb.append(ch);
848                 isRepetition = false;
849             }
850         }
851         return sb.toString();
852     }
853
854     /*
855      * Delete the given file from device
856      * and delete its row from the downloads database.
857      */
858     /* package */ static void deleteFile(ContentResolver resolver, long id, String path, String mimeType) {
859         try {
860             File file = new File(path);
861             file.delete();
862         } catch (Exception e) {
863             Log.w(Constants.TAG, "file: '" + path + "' couldn't be deleted", e);
864         }
865         resolver.delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, Downloads.Impl._ID + " = ? ",
866                 new String[]{String.valueOf(id)});
867     }
868 }